Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.55% covered (warning)
78.55%
637 / 811
58.21% covered (warning)
58.21%
39 / 67
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResourceLoader
78.55% covered (warning)
78.55%
637 / 811
58.21% covered (warning)
58.21%
39 / 67
1034.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageBlobStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMessageBlobStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDependencyStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setModuleSkinStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 registerTestModules
n/a
0 / 0
n/a
0 / 0
4
 addSource
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 getModuleNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTestSuiteModuleNames
n/a
0 / 0
n/a
0 / 0
1
 isModuleRegistered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModule
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 preloadModuleInfo
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
8.03
 loadModuleDependenciesInternal
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 saveModuleDependenciesInternal
59.26% covered (warning)
59.26%
16 / 27
0.00% covered (danger)
0.00%
0 / 1
12.33
 getSources
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLoadScript
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makeHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 outputErrorAndLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCombinedVersion
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 makeVersionQuery
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 respond
83.33% covered (warning)
83.33%
50 / 60
0.00% covered (danger)
0.00%
0 / 1
26.67
 measureResponseTime
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 sendResponseHeaders
67.74% covered (warning)
67.74%
21 / 31
0.00% covered (danger)
0.00%
0 / 1
16.83
 tryRespondNotModified
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 getSourceMapUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 sendSourceMapVersionMismatch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 sendSourceMapTypeNotImplemented
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeComment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatExceptionNoComment
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 makeModuleResponse
86.96% covered (warning)
86.96%
40 / 46
0.00% covered (danger)
0.00%
0 / 1
23.07
 getOneModuleResponse
98.36% covered (success)
98.36%
60 / 61
0.00% covered (danger)
0.00%
0 / 1
12
 addOneModuleResponse
68.09% covered (warning)
68.09%
32 / 47
0.00% covered (danger)
0.00%
0 / 1
22.31
 ensureNewline
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 getModulesByMessage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addImplementScript
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
9
 addFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 addFileContent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 concatenatePlainScripts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addPlainScripts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isEmptyFileInfos
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeCombinedStyles
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 encodeJsonForScript
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 makeLoaderStateScript
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isEmptyObject
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 trimArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 makeLoaderRegisterScript
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 makeLoaderSourcesScript
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 makeLoaderConditionalScript
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeInlineCodeWithModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeInlineScript
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 makeConfigSetScript
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 makePackedModulesString
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 expandModuleNames
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 inDebugMode
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 clearCache
n/a
0 / 0
n/a
0 / 0
1
 createLoaderURL
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createLoaderQuery
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 makeLoaderQuery
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
9.07
 isValidModuleName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getLessCompiler
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
8.13
 expandUrl
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 filter
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
4
 applyFilter
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
7.35
 getUserDefaults
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getSiteConfigSettings
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
12
 getErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @author Roan Kattouw
20 * @author Trevor Parscal
21 */
22
23namespace MediaWiki\ResourceLoader;
24
25use BagOStuff;
26use Exception;
27use ExtensionRegistry;
28use HashBagOStuff;
29use HttpStatus;
30use InvalidArgumentException;
31use Less_Environment;
32use Less_Parser;
33use LogicException;
34use MediaWiki\CommentStore\CommentStore;
35use MediaWiki\Config\Config;
36use MediaWiki\Deferred\DeferredUpdates;
37use MediaWiki\HookContainer\HookContainer;
38use MediaWiki\Html\Html;
39use MediaWiki\Html\HtmlJsCode;
40use MediaWiki\MainConfigNames;
41use MediaWiki\MediaWikiServices;
42use MediaWiki\Output\OutputPage;
43use MediaWiki\Profiler\ProfilingContext;
44use MediaWiki\Request\HeaderCallback;
45use MediaWiki\Request\WebRequest;
46use MediaWiki\Title\Title;
47use MediaWiki\User\Options\UserOptionsLookup;
48use MediaWiki\WikiMap\WikiMap;
49use MWExceptionHandler;
50use MWExceptionRenderer;
51use Net_URL2;
52use ObjectCache;
53use Psr\Log\LoggerAwareInterface;
54use Psr\Log\LoggerInterface;
55use Psr\Log\NullLogger;
56use RuntimeException;
57use stdClass;
58use Throwable;
59use UnexpectedValueException;
60use Wikimedia\DependencyStore\DependencyStore;
61use Wikimedia\DependencyStore\KeyValueDependencyStore;
62use Wikimedia\Minify\CSSMin;
63use Wikimedia\Minify\IdentityMinifierState;
64use Wikimedia\Minify\IndexMap;
65use Wikimedia\Minify\IndexMapOffset;
66use Wikimedia\Minify\JavaScriptMapperState;
67use Wikimedia\Minify\JavaScriptMinifier;
68use Wikimedia\Minify\JavaScriptMinifierState;
69use Wikimedia\Minify\MinifierState;
70use Wikimedia\RequestTimeout\TimeoutException;
71use Wikimedia\ScopedCallback;
72use Wikimedia\Stats\StatsFactory;
73use Wikimedia\Timestamp\ConvertibleTimestamp;
74use Wikimedia\WrappedString;
75
76/**
77 * @defgroup ResourceLoader ResourceLoader
78 *
79 * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
80 */
81
82/**
83 * @defgroup ResourceLoaderHooks ResourceLoader Hooks
84 * @ingroup ResourceLoader
85 * @ingroup Hooks
86 */
87
88/**
89 * ResourceLoader is a loading system for JavaScript and CSS resources.
90 *
91 * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
92 *
93 * @ingroup ResourceLoader
94 * @since 1.17
95 */
96class ResourceLoader implements LoggerAwareInterface {
97    /** @var int */
98    public const CACHE_VERSION = 9;
99    /** @var string JavaScript / CSS pragma to disable minification. * */
100    public const FILTER_NOMIN = '/*@nomin*/';
101
102    /** @var string */
103    private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
104    /** @var int How long to preserve indirect dependency metadata in our backend store. */
105    private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
106    /** @var int */
107    private const MAXAGE_RECOVER = 60;
108
109    /** @var int|null */
110    protected static $debugMode = null;
111
112    /** @var Config */
113    private $config;
114    /** @var MessageBlobStore */
115    private $blobStore;
116    /** @var DependencyStore */
117    private $depStore;
118    /** @var LoggerInterface */
119    private $logger;
120    /** @var HookContainer */
121    private $hookContainer;
122    /** @var BagOStuff */
123    private $srvCache;
124    /** @var StatsFactory */
125    private $statsFactory;
126    /** @var int */
127    private $maxageVersioned;
128    /** @var int */
129    private $maxageUnversioned;
130
131    /** @var Module[] Map of (module name => Module) */
132    private $modules = [];
133    /** @var array[] Map of (module name => associative info array) */
134    private $moduleInfos = [];
135    /** @var string[] List of module names that contain QUnit tests */
136    private $testModuleNames = [];
137    /** @var string[] Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
138    private $sources = [];
139    /** @var array Errors accumulated during a respond() call. Exposed for testing. */
140    protected $errors = [];
141    /**
142     * @var string[] Buffer for extra response headers during a makeModuleResponse() call.
143     * Exposed for testing.
144     */
145    protected $extraHeaders = [];
146    /** @var array Map of (module-variant => buffered DependencyStore updates) */
147    private $depStoreUpdateBuffer = [];
148    /**
149     * @var array Styles that are skin-specific and supplement or replace the
150     * default skinStyles of a FileModule. See $wgResourceModuleSkinStyles.
151     */
152    private $moduleSkinStyles = [];
153
154    /**
155     * @internal For ServiceWiring only (TODO: Make stable as part of T32956).
156     * @param Config $config Generic pass-through for use by extension callbacks
157     *  and other MediaWiki-specific module classes.
158     * @param LoggerInterface|null $logger [optional]
159     * @param DependencyStore|null $tracker [optional]
160     * @param array $params [optional]
161     *  - loadScript: URL path to the load.php entrypoint.
162     *    Default: `'/load.php'`.
163     *  - maxageVersioned: HTTP cache max-age in seconds for URLs with a "version" parameter.
164     *    This applies to most load.php responses, and may have a long duration (e.g. weeks or
165     *    months), because a change in the module bundle will naturally produce a different URL
166     *    and thus automatically bust the CDN and web browser caches.
167     *    Default: 30 days.
168     *  - maxageUnversioned: HTTP cache max-age in seconds for URLs without a "version" parameter.
169     *    This should have a short duration (e.g. minutes), and affects the startup manifest which
170     *    controls how quickly changes (in the module registry, dependency tree, or module content)
171     *    will propagate to clients.
172     *    Default: 5 minutes.
173     */
174    public function __construct(
175        Config $config,
176        LoggerInterface $logger = null,
177        DependencyStore $tracker = null,
178        array $params = []
179    ) {
180        $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
181        $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
182
183        $this->config = $config;
184        $this->logger = $logger ?: new NullLogger();
185
186        $services = MediaWikiServices::getInstance();
187        $this->hookContainer = $services->getHookContainer();
188
189        $this->srvCache = $services->getLocalServerObjectCache();
190        $this->statsFactory = $services->getStatsFactory();
191
192        // Add 'local' source first
193        $this->addSource( 'local', $params['loadScript'] ?? '/load.php' );
194
195        // Special module that always exists
196        $this->register( 'startup', [ 'class' => StartUpModule::class ] );
197
198        $this->setMessageBlobStore(
199            new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
200        );
201
202        $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
203        $this->setDependencyStore( $tracker );
204    }
205
206    /**
207     * @return Config
208     */
209    public function getConfig() {
210        return $this->config;
211    }
212
213    /**
214     * @since 1.26
215     * @param LoggerInterface $logger
216     */
217    public function setLogger( LoggerInterface $logger ) {
218        $this->logger = $logger;
219    }
220
221    /**
222     * @since 1.27
223     * @return LoggerInterface
224     */
225    public function getLogger() {
226        return $this->logger;
227    }
228
229    /**
230     * @since 1.26
231     * @return MessageBlobStore
232     */
233    public function getMessageBlobStore() {
234        return $this->blobStore;
235    }
236
237    /**
238     * @since 1.25
239     * @param MessageBlobStore $blobStore
240     */
241    public function setMessageBlobStore( MessageBlobStore $blobStore ) {
242        $this->blobStore = $blobStore;
243    }
244
245    /**
246     * @since 1.35
247     * @param DependencyStore $tracker
248     */
249    public function setDependencyStore( DependencyStore $tracker ) {
250        $this->depStore = $tracker;
251    }
252
253    /**
254     * @internal For use by ServiceWiring.php
255     * @param array $moduleSkinStyles
256     */
257    public function setModuleSkinStyles( array $moduleSkinStyles ) {
258        $this->moduleSkinStyles = $moduleSkinStyles;
259    }
260
261    /**
262     * Register a module with the ResourceLoader system.
263     *
264     * @see $wgResourceModules for the available options.
265     * @param string|array[] $name Module name as a string or, array of module info arrays
266     *  keyed by name.
267     * @param array|null $info Module info array. When using the first parameter to register
268     *  multiple modules at once, this parameter is optional.
269     * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas)
270     * @throws InvalidArgumentException If the module info is not an array
271     */
272    public function register( $name, array $info = null ) {
273        // Allow multiple modules to be registered in one call
274        $registrations = is_array( $name ) ? $name : [ $name => $info ];
275        foreach ( $registrations as $name => $info ) {
276            // Warn on duplicate registrations
277            if ( isset( $this->moduleInfos[$name] ) ) {
278                // A module has already been registered by this name
279                $this->logger->warning(
280                    'ResourceLoader duplicate registration warning. ' .
281                    'Another module has already been registered as ' . $name
282                );
283            }
284
285            // Check validity
286            if ( !self::isValidModuleName( $name ) ) {
287                throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
288                    . "see ResourceLoader::isValidModuleName()" );
289            }
290            if ( !is_array( $info ) ) {
291                throw new InvalidArgumentException(
292                    'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
293                );
294            }
295
296            // Attach module
297            $this->moduleInfos[$name] = $info;
298        }
299    }
300
301    /**
302     * @internal For use by ServiceWiring only
303     * @codeCoverageIgnore
304     */
305    public function registerTestModules(): void {
306        $extRegistry = ExtensionRegistry::getInstance();
307        $testModules = $extRegistry->getAttribute( 'QUnitTestModules' );
308
309        $testModuleNames = [];
310        foreach ( $testModules as $name => &$module ) {
311            // Turn any single-module dependency into an array
312            if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
313                $module['dependencies'] = [ $module['dependencies'] ];
314            }
315
316            // Ensure the testrunner loads before any tests
317            $module['dependencies'][] = 'mediawiki.qunit-testrunner';
318
319            // Keep track of the modules to load on SpecialJavaScriptTest
320            $testModuleNames[] = $name;
321        }
322
323        // Core test modules (their names have further precedence).
324        $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
325        $testModuleNames[] = 'test.MediaWiki';
326
327        $this->register( $testModules );
328        $this->testModuleNames = $testModuleNames;
329    }
330
331    /**
332     * Add a foreign source of modules.
333     *
334     * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
335     *
336     * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
337     * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for
338     *  backwards-compatibility.
339     * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key.
340     */
341    public function addSource( $sources, $loadUrl = null ) {
342        if ( !is_array( $sources ) ) {
343            $sources = [ $sources => $loadUrl ];
344        }
345        foreach ( $sources as $id => $source ) {
346            // Disallow duplicates
347            if ( isset( $this->sources[$id] ) ) {
348                throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
349            }
350
351            // Support: MediaWiki 1.24 and earlier
352            if ( is_array( $source ) ) {
353                if ( !isset( $source['loadScript'] ) ) {
354                    throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
355                }
356                $source = $source['loadScript'];
357            }
358
359            $this->sources[$id] = $source;
360        }
361    }
362
363    /**
364     * @return string[]
365     */
366    public function getModuleNames() {
367        return array_keys( $this->moduleInfos );
368    }
369
370    /**
371     * Get a list of modules with QUnit tests.
372     *
373     * @internal For use by SpecialJavaScriptTest only
374     * @return string[]
375     * @codeCoverageIgnore
376     */
377    public function getTestSuiteModuleNames() {
378        return $this->testModuleNames;
379    }
380
381    /**
382     * Check whether a ResourceLoader module is registered
383     *
384     * @since 1.25
385     * @param string $name
386     * @return bool
387     */
388    public function isModuleRegistered( $name ) {
389        return isset( $this->moduleInfos[$name] );
390    }
391
392    /**
393     * Get the Module object for a given module name.
394     *
395     * If an array of module parameters exists but a Module object has not yet
396     * been instantiated, this method will instantiate and cache that object such that
397     * subsequent calls simply return the same object.
398     *
399     * @param string $name Module name
400     * @return Module|null If module has been registered, return a
401     *  Module instance. Otherwise, return null.
402     */
403    public function getModule( $name ) {
404        if ( !isset( $this->modules[$name] ) ) {
405            if ( !isset( $this->moduleInfos[$name] ) ) {
406                // No such module
407                return null;
408            }
409            // Construct the requested module object
410            $info = $this->moduleInfos[$name];
411            if ( isset( $info['factory'] ) ) {
412                /** @var Module $object */
413                $object = call_user_func( $info['factory'], $info );
414            } else {
415                $class = $info['class'] ?? FileModule::class;
416                /** @var Module $object */
417                $object = new $class( $info );
418            }
419            $object->setConfig( $this->getConfig() );
420            $object->setLogger( $this->logger );
421            $object->setHookContainer( $this->hookContainer );
422            $object->setName( $name );
423            $object->setDependencyAccessCallbacks(
424                [ $this, 'loadModuleDependenciesInternal' ],
425                [ $this, 'saveModuleDependenciesInternal' ]
426            );
427            $object->setSkinStylesOverride( $this->moduleSkinStyles );
428            $this->modules[$name] = $object;
429        }
430
431        return $this->modules[$name];
432    }
433
434    /**
435     * Load information stored in the database and dependency tracking store about modules
436     *
437     * @param string[] $moduleNames
438     * @param Context $context ResourceLoader-specific context of the request
439     */
440    public function preloadModuleInfo( array $moduleNames, Context $context ) {
441        // Load all tracked indirect file dependencies for the modules
442        $vary = Module::getVary( $context );
443        $entitiesByModule = [];
444        foreach ( $moduleNames as $moduleName ) {
445            $entitiesByModule[$moduleName] = "$moduleName|$vary";
446        }
447        $depsByEntity = $this->depStore->retrieveMulti(
448            self::RL_DEP_STORE_PREFIX,
449            $entitiesByModule
450        );
451        // Inject the indirect file dependencies for all the modules
452        foreach ( $moduleNames as $moduleName ) {
453            $module = $this->getModule( $moduleName );
454            if ( $module ) {
455                $entity = $entitiesByModule[$moduleName];
456                $deps = $depsByEntity[$entity];
457                $paths = Module::expandRelativePaths( $deps['paths'] );
458                $module->setFileDependencies( $context, $paths );
459            }
460        }
461
462        WikiModule::preloadTitleInfo( $context, $moduleNames );
463
464        // Prime in-object cache for message blobs for modules with messages
465        $modulesWithMessages = [];
466        foreach ( $moduleNames as $moduleName ) {
467            $module = $this->getModule( $moduleName );
468            if ( $module && $module->getMessages() ) {
469                $modulesWithMessages[$moduleName] = $module;
470            }
471        }
472        // Prime in-object cache for message blobs for modules with messages
473        $lang = $context->getLanguage();
474        $store = $this->getMessageBlobStore();
475        $blobs = $store->getBlobs( $modulesWithMessages, $lang );
476        foreach ( $blobs as $moduleName => $blob ) {
477            $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
478        }
479    }
480
481    /**
482     * @internal Exposed for letting getModule() pass the callable to DependencyStore
483     * @param string $moduleName
484     * @param string $variant Language/skin variant
485     * @return string[] List of absolute file paths
486     */
487    public function loadModuleDependenciesInternal( $moduleName, $variant ) {
488        $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
489
490        return Module::expandRelativePaths( $deps['paths'] );
491    }
492
493    /**
494     * @internal Exposed for letting getModule() pass the callable to DependencyStore
495     * @param string $moduleName
496     * @param string $variant Language/skin variant
497     * @param string[] $paths List of relative paths referenced during computation
498     * @param string[] $priorPaths List of relative paths tracked in the dependency store
499     */
500    public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
501        $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
502        $entity = "$moduleName|$variant";
503
504        if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
505            // Dependency store needs to be updated with the new path list
506            if ( $paths ) {
507                $deps = $this->depStore->newEntityDependencies( $paths, time() );
508                $this->depStoreUpdateBuffer[$entity] = $deps;
509            } else {
510                $this->depStoreUpdateBuffer[$entity] = null;
511            }
512        }
513
514        // If paths were unchanged, leave the dependency store unchanged also.
515        // The entry will eventually expire, after which we will briefly issue an incomplete
516        // version hash for a 5-min startup window, the module then recomputes and rediscovers
517        // the paths and arrive at the same module version hash once again. It will churn
518        // part of the browser cache once, for clients connecting during that window.
519
520        if ( !$hasPendingUpdate ) {
521            DeferredUpdates::addCallableUpdate( function () {
522                $updatesByEntity = $this->depStoreUpdateBuffer;
523                $this->depStoreUpdateBuffer = [];
524                $cache = ObjectCache::getLocalClusterInstance();
525
526                $scopeLocks = [];
527                $depsByEntity = [];
528                $entitiesUnreg = [];
529                foreach ( $updatesByEntity as $entity => $update ) {
530                    $lockKey = $cache->makeKey( 'rl-deps', $entity );
531                    $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
532                    if ( !$scopeLocks[$entity] ) {
533                        // avoid duplicate write request slams (T124649)
534                        // the lock must be specific to the current wiki (T247028)
535                        continue;
536                    }
537                    if ( $update === null ) {
538                        $entitiesUnreg[] = $entity;
539                    } else {
540                        $depsByEntity[$entity] = $update;
541                    }
542                }
543
544                $ttl = self::RL_MODULE_DEP_TTL;
545                $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
546                $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
547            } );
548        }
549    }
550
551    /**
552     * Get the list of sources.
553     *
554     * @return array Like [ id => load.php url, ... ]
555     */
556    public function getSources() {
557        return $this->sources;
558    }
559
560    /**
561     * Get the URL to the load.php endpoint for the given ResourceLoader source.
562     *
563     * @since 1.24
564     * @param string $source Source ID
565     * @return string
566     * @throws UnexpectedValueException If the source ID was not registered
567     */
568    public function getLoadScript( $source ) {
569        if ( !isset( $this->sources[$source] ) ) {
570            throw new UnexpectedValueException( "Unknown source '$source'" );
571        }
572        return $this->sources[$source];
573    }
574
575    /**
576     * @internal For use by StartUpModule only.
577     */
578    public const HASH_LENGTH = 5;
579
580    /**
581     * Create a hash for module versioning purposes.
582     *
583     * This hash is used in three ways:
584     *
585     * - To differentiate between the current version and a past version
586     *   of a module by the same name.
587     *
588     *   In the cache key of localStorage in the browser (mw.loader.store).
589     *   This store keeps only one version of any given module. As long as the
590     *   next version the client encounters has a different hash from the last
591     *   version it saw, it will correctly discard it in favour of a network fetch.
592     *
593     *   A browser may evict a site's storage container for any reason (e.g. when
594     *   the user hasn't visited a site for some time, and/or when the device is
595     *   low on storage space). Anecdotally it seems devices rarely keep unused
596     *   storage beyond 2 weeks on mobile devices and 4 weeks on desktop.
597     *   But, there is no hard limit or expiration on localStorage.
598     *   ResourceLoader's Client also clears localStorage when the user changes
599     *   their language preference or when they (temporarily) use Debug Mode.
600     *
601     *   The only hard factors that reduce the range of possible versions are
602     *   1) the name and existence of a given module, and
603     *   2) the TTL for mw.loader.store, and
604     *   3) the `$wgResourceLoaderStorageVersion` configuration variable.
605     *
606     * - To identify a batch response of modules from load.php in an HTTP cache.
607     *
608     *   When fetching modules in a batch from load.php, a combined hash
609     *   is created by the JS code, and appended as query parameter.
610     *
611     *   In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache,
612     *   these urls are used to identify other previously cached responses.
613     *   The range of possible versions a given version has to be unique amongst
614     *   is determined by the maximum duration each response is stored for, which
615     *   is controlled by `$wgResourceLoaderMaxage['versioned']`.
616     *
617     * - To detect race conditions between multiple web servers in a MediaWiki
618     *   deployment of which some have the newer version and some still the older
619     *   version.
620     *
621     *   An HTTP request from a browser for the Startup manifest may be responded
622     *   to by a server with the newer version. The browser may then use that to
623     *   request a given module, which may then be responded to by a server with
624     *   the older version. To avoid caching this for too long (which would pollute
625     *   all other users without repairing itself), the combined hash that the JS
626     *   client adds to the url is verified by the server (in ::sendResponseHeaders).
627     *   If they don't match, we instruct cache proxies and clients to not cache
628     *   this response as long as they normally would. This is also the reason
629     *   that the algorithm used here in PHP must match the one used in JS.
630     *
631     * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and
632     * needs up to 7 chars in base 36.
633     * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga),
634     * (but with fnv132 we'd use very little of this range, mostly padding).
635     * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga).
636     * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega).
637     *
638     * @since 1.26
639     * @param string $value
640     * @return string Hash
641     */
642    public static function makeHash( $value ) {
643        $hash = hash( 'fnv132', $value );
644        // The base_convert will pad it (if too short),
645        // then substr() will trim it (if too long).
646        return substr(
647            \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
648            0,
649            self::HASH_LENGTH
650        );
651    }
652
653    /**
654     * Add an error to the 'errors' array and log it.
655     *
656     * @internal For use by StartUpModule.
657     * @since 1.29
658     * @param Exception $e
659     * @param string $msg
660     * @param array $context
661     */
662    public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
663        MWExceptionHandler::logException( $e );
664        $this->logger->warning(
665            $msg,
666            $context + [ 'exception' => $e ]
667        );
668        $this->errors[] = self::formatExceptionNoComment( $e );
669    }
670
671    /**
672     * Helper method to get and combine versions of multiple modules.
673     *
674     * @since 1.26
675     * @param Context $context
676     * @param string[] $moduleNames List of known module names
677     * @return string Hash
678     */
679    public function getCombinedVersion( Context $context, array $moduleNames ) {
680        if ( !$moduleNames ) {
681            return '';
682        }
683        $hashes = [];
684        foreach ( $moduleNames as $module ) {
685            try {
686                $hash = $this->getModule( $module )->getVersionHash( $context );
687            } catch ( TimeoutException $e ) {
688                throw $e;
689            } catch ( Exception $e ) {
690                // If modules fail to compute a version, don't fail the request (T152266)
691                // and still compute versions of other modules.
692                $this->outputErrorAndLog( $e,
693                    'Calculating version for "{module}" failed: {exception}',
694                    [
695                        'module' => $module,
696                    ]
697                );
698                $hash = '';
699            }
700            $hashes[] = $hash;
701        }
702        return self::makeHash( implode( '', $hashes ) );
703    }
704
705    /**
706     * Get the expected value of the 'version' query parameter.
707     *
708     * This is used by respond() to set a short Cache-Control header for requests with
709     * information newer than the current server has. This avoids pollution of edge caches.
710     * Typically during deployment. (T117587)
711     *
712     * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
713     *
714     * @since 1.28
715     * @param Context $context
716     * @param string[] $modules
717     * @return string Hash
718     */
719    public function makeVersionQuery( Context $context, array $modules ) {
720        // As of MediaWiki 1.28, the server and client use the same algorithm for combining
721        // version hashes. There is no technical reason for this to be same, and for years the
722        // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
723        // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
724        // query parameter), then this method must continue to match the JS one.
725        $filtered = [];
726        foreach ( $modules as $name ) {
727            if ( !$this->getModule( $name ) ) {
728                // If a versioned request contains a missing module, the version is a mismatch
729                // as the client considered a module (and version) we don't have.
730                return '';
731            }
732            $filtered[] = $name;
733        }
734        return $this->getCombinedVersion( $context, $filtered );
735    }
736
737    /**
738     * Output a response to a load request, including the content-type header.
739     *
740     * @param Context $context Context in which a response should be formed
741     */
742    public function respond( Context $context ) {
743        // Buffer output to catch warnings. Normally we'd use ob_clean() on the
744        // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
745        // is used: ob_clean() will clear the GZIP header in that case and it won't come
746        // back for subsequent output, resulting in invalid GZIP. So we have to wrap
747        // the whole thing in our own output buffer to be sure the active buffer
748        // doesn't use ob_gzhandler.
749        // See https://bugs.php.net/bug.php?id=36514
750        ob_start();
751
752        $this->errors = [];
753        $responseTime = $this->measureResponseTime();
754        ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' );
755
756        // Find out which modules are missing and instantiate the others
757        $modules = [];
758        $missing = [];
759        foreach ( $context->getModules() as $name ) {
760            $module = $this->getModule( $name );
761            if ( $module ) {
762                // Do not allow private modules to be loaded from the web.
763                // This is a security issue, see T36907.
764                if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
765                    // Not a serious error, just means something is trying to access it (T101806)
766                    $this->logger->debug( "Request for private module '$name' denied" );
767                    $this->errors[] = "Cannot build private module \"$name\"";
768                    continue;
769                }
770                $modules[$name] = $module;
771            } else {
772                $missing[] = $name;
773            }
774        }
775
776        try {
777            // Preload for getCombinedVersion() and for batch makeModuleResponse()
778            $this->preloadModuleInfo( array_keys( $modules ), $context );
779        } catch ( TimeoutException $e ) {
780            throw $e;
781        } catch ( Exception $e ) {
782            $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
783        }
784
785        // Combine versions to propagate cache invalidation
786        $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
787
788        // See RFC 2616 Â§ 3.11 Entity Tags
789        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
790        $etag = 'W/"' . $versionHash . '"';
791
792        // Try the client-side cache first
793        if ( $this->tryRespondNotModified( $context, $etag ) ) {
794            return; // output handled (buffers cleared)
795        }
796
797        if ( $context->isSourceMap() ) {
798            // In source map mode, a version mismatch should be a 404
799            if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) {
800                ob_end_clean();
801                $this->sendSourceMapVersionMismatch( $versionHash );
802                return;
803            }
804            // No source maps for images, only=styles requests, or debug mode
805            if ( $context->getImage()
806                || $context->getOnly() === 'styles'
807                || $context->getDebug()
808            ) {
809                ob_end_clean();
810                $this->sendSourceMapTypeNotImplemented();
811                return;
812            }
813        }
814        // Emit source map header if supported (inverse of the above check)
815        if ( $this->config->get( MainConfigNames::ResourceLoaderEnableSourceMapLinks )
816            && !$context->getImageObj()
817            && !$context->isSourceMap()
818            && $context->shouldIncludeScripts()
819            && !$context->getDebug()
820        ) {
821            $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
822        }
823
824        // Generate a response
825        $response = $this->makeModuleResponse( $context, $modules, $missing );
826
827        // Capture any PHP warnings from the output buffer and append them to the
828        // error list if we're in debug mode.
829        if ( $context->getDebug() ) {
830            $warnings = ob_get_contents();
831            if ( strlen( $warnings ) ) {
832                $this->errors[] = $warnings;
833            }
834        }
835
836        $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
837
838        // Remove the output buffer and output the response
839        ob_end_clean();
840
841        if ( $context->getImageObj() && $this->errors ) {
842            // We can't show both the error messages and the response when it's an image.
843            $response = implode( "\n\n", $this->errors );
844        } elseif ( $this->errors ) {
845            $errorText = implode( "\n\n", $this->errors );
846            $errorResponse = self::makeComment( $errorText );
847            if ( $context->shouldIncludeScripts() ) {
848                $errorResponse .= 'if (window.console && console.error) { console.error('
849                    . $context->encodeJson( $errorText )
850                    . "); }\n";
851                // Append the error info to the response
852                // We used to prepend it, but that would corrupt the source map
853                $response .= $errorResponse;
854            } else {
855                // For styles we can still prepend
856                $response = $errorResponse . $response;
857            }
858        }
859
860        echo $response;
861    }
862
863    /**
864     * Send stats about the time used to build the response
865     * @return ScopedCallback
866     */
867    protected function measureResponseTime() {
868        $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
869        return new ScopedCallback( function () use ( $statStart ) {
870            $statTiming = microtime( true ) - $statStart;
871
872            $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' )
873                ->copyToStatsdAt( 'resourceloader.responseTime' )
874                ->observe( 1000 * $statTiming );
875        } );
876    }
877
878    /**
879     * Send main response headers to the client.
880     *
881     * Deals with Content-Type, CORS (for stylesheets), and caching.
882     *
883     * @param Context $context
884     * @param string $etag ETag header value
885     * @param bool $errors Whether there are errors in the response
886     * @param string[] $extra Array of extra HTTP response headers
887     */
888    protected function sendResponseHeaders(
889        Context $context, $etag, $errors, array $extra = []
890    ): void {
891        HeaderCallback::warnIfHeadersSent();
892
893        if ( $errors ) {
894            $maxage = self::MAXAGE_RECOVER;
895        } elseif (
896            $context->getVersion() !== null
897            && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
898        ) {
899            // If we need to self-correct, set a very short cache expiry
900            // to basically just debounce CDN traffic. This applies to:
901            // - Internal errors, e.g. due to misconfiguration.
902            // - Version mismatch, e.g. due to deployment race (T117587, T47877).
903            $this->logger->debug( 'Client and server registry version out of sync' );
904            $maxage = self::MAXAGE_RECOVER;
905        } elseif ( $context->getVersion() === null ) {
906            // Resources that can't set a version, should have their updates propagate to
907            // clients quickly. This applies to shared resources linked from HTML, such as
908            // the startup module and stylesheets.
909            $maxage = $this->maxageUnversioned;
910        } else {
911            // When a version is set, use a long expiry because changes
912            // will naturally miss the cache by using a different URL.
913            $maxage = $this->maxageVersioned;
914        }
915        if ( $context->getImageObj() ) {
916            // Output different headers if we're outputting textual errors.
917            if ( $errors ) {
918                header( 'Content-Type: text/plain; charset=utf-8' );
919            } else {
920                $context->getImageObj()->sendResponseHeaders( $context );
921            }
922        } elseif ( $context->isSourceMap() ) {
923            header( 'Content-Type: application/json' );
924        } elseif ( $context->getOnly() === 'styles' ) {
925            header( 'Content-Type: text/css; charset=utf-8' );
926            header( 'Access-Control-Allow-Origin: *' );
927        } else {
928            header( 'Content-Type: text/javascript; charset=utf-8' );
929        }
930        // See RFC 2616 Â§ 14.19 ETag
931        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
932        header( 'ETag: ' . $etag );
933        if ( $context->getDebug() ) {
934            // Do not cache debug responses
935            header( 'Cache-Control: private, no-cache, must-revalidate' );
936        } else {
937            // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
938            // the background instead of blocking the next page load (eg. startup module, or CSS).
939            $staleDirective = ( $maxage > self::MAXAGE_RECOVER
940                ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
941                : ''
942            );
943            header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
944            header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
945        }
946        foreach ( $extra as $header ) {
947            header( $header );
948        }
949    }
950
951    /**
952     * Respond with HTTP 304 Not Modified if appropriate.
953     *
954     * If there's an If-None-Match header, respond with a 304 appropriately
955     * and clear out the output buffer. If the client cache is too old then do nothing.
956     *
957     * @param Context $context
958     * @param string $etag ETag header value
959     * @return bool True if HTTP 304 was sent and output handled
960     */
961    protected function tryRespondNotModified( Context $context, $etag ) {
962        // See RFC 2616 Â§ 14.26 If-None-Match
963        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
964        $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
965        // Never send 304s in debug mode
966        if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
967            // There's another bug in ob_gzhandler (see also the comment at
968            // the top of this function) that causes it to gzip even empty
969            // responses, meaning it's impossible to produce a truly empty
970            // response (because the gzip header is always there). This is
971            // a problem because 304 responses have to be completely empty
972            // per the HTTP spec, and Firefox behaves buggily when they're not.
973            // See also https://bugs.php.net/bug.php?id=51579
974            // To work around this, we tear down all output buffering before
975            // sending the 304.
976            wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
977
978            HttpStatus::header( 304 );
979
980            $this->sendResponseHeaders( $context, $etag, false );
981            return true;
982        }
983        return false;
984    }
985
986    /**
987     * Get the URL which will deliver the source map for the current response.
988     *
989     * @param Context $context
990     * @param string $version The combined version hash
991     * @return string
992     */
993    private function getSourceMapUrl( Context $context, $version ) {
994        return $this->createLoaderURL( 'local', $context, [
995            'sourcemap' => '1',
996            'version' => $version
997        ] );
998    }
999
1000    /**
1001     * Send an error page for a source map version mismatch
1002     *
1003     * @param string $currentVersion
1004     */
1005    private function sendSourceMapVersionMismatch( $currentVersion ) {
1006        HttpStatus::header( 404 );
1007        header( 'Content-Type: text/plain; charset=utf-8' );
1008        header( 'X-Content-Type-Options: nosniff' );
1009        echo "Can't deliver a source map for the requested version " .
1010            "since the version is now '$currentVersion'\n";
1011    }
1012
1013    /**
1014     * Send an error page when a source map is requested but there is no
1015     * support for the specified content type
1016     */
1017    private function sendSourceMapTypeNotImplemented() {
1018        HttpStatus::header( 404 );
1019        header( 'Content-Type: text/plain; charset=utf-8' );
1020        header( 'X-Content-Type-Options: nosniff' );
1021        echo "Can't make a source map for this content type\n";
1022    }
1023
1024    /**
1025     * Generate a CSS or JS comment block.
1026     *
1027     * Only use this for public data, not error message details.
1028     *
1029     * @param string $text
1030     * @return string
1031     */
1032    public static function makeComment( $text ) {
1033        $encText = str_replace( '*/', '* /', $text );
1034        return "/*\n$encText\n*/\n";
1035    }
1036
1037    /**
1038     * Handle exception display.
1039     *
1040     * @since 1.25
1041     * @param Throwable $e Exception to be shown to the user
1042     * @return string Sanitized text for a CSS/JS comment that can be returned to the user
1043     */
1044    protected static function formatExceptionNoComment( Throwable $e ) {
1045        if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1046            return MWExceptionHandler::getPublicLogMessage( $e );
1047        }
1048
1049        // Like MWExceptionHandler::getLogMessage but without $url and $id.
1050        // - Long load.php URL would push the actual error message off-screen into
1051        //   scroll overflow in browser devtools.
1052        // - reqId is redundant with X-Request-Id header, plus usually no need to
1053        //   correlate the reqId since the backtrace is already included below.
1054        $type = get_class( $e );
1055        $message = $e->getMessage();
1056
1057        return "$type$message" .
1058            "\nBacktrace:\n" .
1059            MWExceptionHandler::getRedactedTraceAsString( $e );
1060    }
1061
1062    /**
1063     * Generate code for a response.
1064     *
1065     * Calling this method also populates the `errors` and `headers` members,
1066     * later used by respond().
1067     *
1068     * @param Context $context Context in which to generate a response
1069     * @param Module[] $modules List of module objects keyed by module name
1070     * @param string[] $missing List of requested module names that are unregistered (optional)
1071     * @return string Response data
1072     */
1073    public function makeModuleResponse( Context $context,
1074        array $modules, array $missing = []
1075    ) {
1076        if ( $modules === [] && $missing === [] ) {
1077            return <<<MESSAGE
1078/* This file is the Web entry point for MediaWiki's ResourceLoader:
1079   <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1080   no modules were requested. Max made me put this here. */
1081MESSAGE;
1082        }
1083
1084        $image = $context->getImageObj();
1085        if ( $image ) {
1086            $data = $image->getImageData( $context );
1087            if ( $data === false ) {
1088                $data = '';
1089                $this->errors[] = 'Image generation failed';
1090            }
1091            return $data;
1092        }
1093
1094        $states = [];
1095        foreach ( $missing as $name ) {
1096            $states[$name] = 'missing';
1097        }
1098
1099        $only = $context->getOnly();
1100        $debug = (bool)$context->getDebug();
1101        if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1102            $indexMap = new IndexMap;
1103        } else {
1104            $indexMap = null;
1105        }
1106
1107        $out = '';
1108        foreach ( $modules as $name => $module ) {
1109            try {
1110                [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1111                if ( $indexMap ) {
1112                    $indexMap->addEncodedMap( $response, $offset );
1113                } else {
1114                    $out .= $response;
1115                }
1116            } catch ( TimeoutException $e ) {
1117                throw $e;
1118            } catch ( Exception $e ) {
1119                $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1120
1121                // Respond to client with error-state instead of module implementation
1122                $states[$name] = 'error';
1123                unset( $modules[$name] );
1124            }
1125        }
1126
1127        // Update module states
1128        if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1129            if ( $modules && $only === 'scripts' ) {
1130                // Set the state of modules loaded as only scripts to ready as
1131                // they don't have an mw.loader.impl wrapper that sets the state
1132                foreach ( $modules as $name => $module ) {
1133                    $states[$name] = 'ready';
1134                }
1135            }
1136
1137            // Set the state of modules we didn't respond to with mw.loader.impl
1138            if ( $states && !$context->isSourceMap() ) {
1139                $stateScript = self::makeLoaderStateScript( $context, $states );
1140                if ( !$debug ) {
1141                    $stateScript = self::filter( 'minify-js', $stateScript );
1142                }
1143                // Use a linebreak between module script and state script (T162719)
1144                $out = self::ensureNewline( $out ) . $stateScript;
1145            }
1146        } elseif ( $states ) {
1147            $this->errors[] = 'Problematic modules: '
1148                // Silently ignore invalid UTF-8 injected via 'modules' query
1149                // Don't issue server-side warnings for client errors. (T331641)
1150                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1151                . @$context->encodeJson( $states );
1152        }
1153
1154        if ( $indexMap ) {
1155            return $indexMap->getMap();
1156        } else {
1157            return $out;
1158        }
1159    }
1160
1161    /**
1162     * Get the response of a single module
1163     *
1164     * @param Context $context
1165     * @param string $name
1166     * @param Module $module
1167     * @return array{string,IndexMapOffset|null}
1168     */
1169    private function getOneModuleResponse( Context $context, $name, Module $module ) {
1170        $only = $context->getOnly();
1171        // Important: Do not cache minifications of embedded modules
1172        // This is especially for the private 'user.options' module,
1173        // which varies on every pageview and would explode the cache (T84960)
1174        $shouldCache = !$module->shouldEmbedModule( $context );
1175        if ( $only === 'styles' ) {
1176            $minifier = new IdentityMinifierState;
1177            $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1178            // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper
1179            // to ease code reuse. The filter() call below performs CSS minification.
1180            $styles = $minifier->getMinifiedOutput();
1181            if ( $context->getDebug() ) {
1182                return [ $styles, null ];
1183            }
1184            return [
1185                self::filter( 'minify-css', $styles,
1186                    [ 'cache' => $shouldCache ] ),
1187                null
1188            ];
1189        }
1190
1191        $minifier = new IdentityMinifierState;
1192        $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1193        $plainContent = $minifier->getMinifiedOutput();
1194        if ( $context->getDebug() ) {
1195            return [ $plainContent, null ];
1196        }
1197
1198        $isHit = true;
1199        $callback = function () use ( $context, $name, $module, &$isHit ) {
1200            $isHit = false;
1201            if ( $context->isSourceMap() ) {
1202                $minifier = ( new JavaScriptMapperState )
1203                    ->outputFile( $this->createLoaderURL( 'local', $context, [
1204                        'modules' => self::makePackedModulesString( $context->getModules() ),
1205                        'only' => $context->getOnly()
1206                    ] ) );
1207            } else {
1208                $minifier = new JavaScriptMinifierState;
1209            }
1210            // We only need to add one set of headers, and we did that for the identity response
1211            $discardedHeaders = null;
1212            $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1213            if ( $context->isSourceMap() ) {
1214                $sourceMap = $minifier->getRawSourceMap();
1215                $generated = $minifier->getMinifiedOutput();
1216                $offset = IndexMapOffset::newFromText( $generated );
1217                return [ $sourceMap, $offset->toArray() ];
1218            } else {
1219                return [ $minifier->getMinifiedOutput(), null ];
1220            }
1221        };
1222
1223        if ( $shouldCache ) {
1224            [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1225                $this->srvCache->makeGlobalKey(
1226                    'resourceloader-mapped',
1227                    self::CACHE_VERSION,
1228                    $name,
1229                    $context->isSourceMap() ? '1' : '0',
1230                    md5( $plainContent )
1231                ),
1232                BagOStuff::TTL_DAY,
1233                $callback
1234            );
1235
1236            $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js';
1237            $statsdNamespace = implode( '.', [
1238                "resourceloader_cache", $mapType, $isHit ? 'hit' : 'miss'
1239            ] );
1240            $this->statsFactory->getCounter( 'resourceloader_cache_total' )
1241                ->setLabel( 'type', $mapType )
1242                ->setLabel( 'status', $isHit ? 'hit' : 'miss' )
1243                ->copyToStatsdAt( [ $statsdNamespace ] )
1244                ->increment();
1245        } else {
1246            [ $response, $offsetArray ] = $callback();
1247        }
1248        $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1249
1250        return [ $response, $offset ];
1251    }
1252
1253    /**
1254     * Add the response of a single module to the MinifierState
1255     *
1256     * @param Context $context
1257     * @param MinifierState $minifier
1258     * @param string $name
1259     * @param Module $module
1260     * @param array|null &$headers Array of headers. If it is not null, the
1261     *   module's headers will be appended to this array.
1262     */
1263    private function addOneModuleResponse(
1264        Context $context, MinifierState $minifier, $name, Module $module, &$headers
1265    ) {
1266        $only = $context->getOnly();
1267        $debug = (bool)$context->getDebug();
1268        $content = $module->getModuleContent( $context );
1269        $version = $module->getVersionHash( $context );
1270
1271        if ( $headers !== null && isset( $content['headers'] ) ) {
1272            $headers = array_merge( $headers, $content['headers'] );
1273        }
1274
1275        // Append output
1276        switch ( $only ) {
1277            case 'scripts':
1278                $scripts = $content['scripts'];
1279                if ( !is_array( $scripts ) ) {
1280                    // Formerly scripts was usually a string, but now it is
1281                    // normalized to an array by buildContent().
1282                    throw new InvalidArgumentException( 'scripts must be an array' );
1283                }
1284                if ( isset( $scripts['plainScripts'] ) ) {
1285                    // Add plain scripts
1286                    $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1287                } elseif ( isset( $scripts['files'] ) ) {
1288                    // Add implement call if any
1289                    $this->addImplementScript(
1290                        $minifier,
1291                        $name,
1292                        $version,
1293                        $scripts,
1294                        [],
1295                        null,
1296                        [],
1297                        $content['deprecationWarning'] ?? null
1298                    );
1299                }
1300                break;
1301            case 'styles':
1302                $styles = $content['styles'];
1303                // We no longer separate into media, they are all combined now with
1304                // custom media type groups into @media .. {} sections as part of the css string.
1305                // Module returns either an empty array or a numerical array with css strings.
1306                if ( isset( $styles['css'] ) ) {
1307                    $minifier->addOutput( implode( '', $styles['css'] ) );
1308                }
1309                break;
1310            default:
1311                $scripts = $content['scripts'] ?? '';
1312                if ( ( $name === 'site' || $name === 'user' )
1313                    && isset( $scripts['plainScripts'] )
1314                ) {
1315                    // Legacy scripts that run in the global scope without a closure.
1316                    // mw.loader.impl will use eval if scripts is a string.
1317                    // Minify manually here, because general response minification is
1318                    // not effective due it being a string literal, not a function.
1319                    $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1320                    if ( !$debug ) {
1321                        $scripts = self::filter( 'minify-js', $scripts ); // T107377
1322                    }
1323                }
1324                $this->addImplementScript(
1325                    $minifier,
1326                    $name,
1327                    $version,
1328                    $scripts,
1329                    $content['styles'] ?? [],
1330                    isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1331                    $content['templates'] ?? [],
1332                    $content['deprecationWarning'] ?? null
1333                );
1334                break;
1335        }
1336        $minifier->ensureNewline();
1337    }
1338
1339    /**
1340     * Ensure the string is either empty or ends in a line break
1341     * @internal
1342     * @param string $str
1343     * @return string
1344     */
1345    public static function ensureNewline( $str ) {
1346        $end = substr( $str, -1 );
1347        if ( $end === false || $end === '' || $end === "\n" ) {
1348            return $str;
1349        }
1350        return $str . "\n";
1351    }
1352
1353    /**
1354     * Get names of modules that use a certain message.
1355     *
1356     * @param string $messageKey
1357     * @return string[] List of module names
1358     */
1359    public function getModulesByMessage( $messageKey ) {
1360        $moduleNames = [];
1361        foreach ( $this->getModuleNames() as $moduleName ) {
1362            $module = $this->getModule( $moduleName );
1363            if ( in_array( $messageKey, $module->getMessages() ) ) {
1364                $moduleNames[] = $moduleName;
1365            }
1366        }
1367        return $moduleNames;
1368    }
1369
1370    /**
1371     * Generate JS code that calls mw.loader.impl with given module properties
1372     * and add it to the MinifierState.
1373     *
1374     * @param MinifierState $minifier The minifier to which output should be appended
1375     * @param string $moduleName The module name
1376     * @param string $version The module version hash
1377     * @param array|string|string[] $scripts
1378     *  - array: Package files array containing strings for individual JS files,
1379     *    as produced by Module::getScript().
1380     *  - string: Script contents to eval in global scope (for site/user scripts).
1381     *  - string[]: List of URLs (for debug mode).
1382     * @param array<string,string|array<string,string[]>> $styles
1383     *   Under optional key "css", there is a concatenated CSS string.
1384     *   Under optional key "url", there is an array by media type withs URLs to stylesheets (for debug mode).
1385     *   These come from Module::getStyles(), formatted by Module:buildContent().
1386     * @param HtmlJsCode|null $messages An already JSON-encoded map from message keys to values,
1387     *   wrapped in an HtmlJsCode object.
1388     * @param array<string,string> $templates Map from template name to template source.
1389     * @param string|null $deprecationWarning
1390     */
1391    private function addImplementScript( MinifierState $minifier,
1392        $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1393    ) {
1394        $implementKey = "$moduleName@$version";
1395        // Plain functions are used instead of arrow functions to avoid
1396        // defeating lazy compilation on Chrome. (T343407)
1397        $minifier->addOutput( "mw.loader.impl(function(){return[" .
1398            Html::encodeJsVar( $implementKey ) . "," );
1399
1400        // Scripts
1401        if ( is_string( $scripts ) ) {
1402            // user/site script
1403            $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1404        } elseif ( is_array( $scripts ) ) {
1405            if ( isset( $scripts['files'] ) ) {
1406                $minifier->addOutput(
1407                    "{\"main\":" .
1408                    Html::encodeJsVar( $scripts['main'] ) .
1409                    ",\"files\":" );
1410                $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1411                $minifier->addOutput( "}" );
1412            } elseif ( isset( $scripts['plainScripts'] ) ) {
1413                if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1414                    $minifier->addOutput( 'null' );
1415                } else {
1416                    $minifier->addOutput( "function($,jQuery,require,module){" );
1417                    $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1418                    $minifier->addOutput( "}" );
1419                }
1420            } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1421                // Array of URLs
1422                $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1423            } else {
1424                throw new InvalidArgumentException( 'Invalid script array: ' .
1425                    'must contain files, plainScripts or be an array of URLs' );
1426            }
1427        } else {
1428            throw new InvalidArgumentException( 'Script must be a string or array' );
1429        }
1430
1431        // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1432        // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1433        // of "{}". Force them to objects.
1434        $extraArgs = [
1435            (object)$styles,
1436            $messages ?? (object)[],
1437            (object)$templates,
1438            $deprecationWarning
1439        ];
1440        self::trimArray( $extraArgs );
1441        foreach ( $extraArgs as $arg ) {
1442            $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1443        }
1444        $minifier->addOutput( "];});" );
1445    }
1446
1447    /**
1448     * Extract the contents of an array of package files, and convert it to a
1449     * JavaScript array. Add the array to the minifier state.
1450     *
1451     * Package files can contain JSON data.
1452     *
1453     * @param MinifierState $minifier
1454     * @param string $moduleName
1455     * @param array $files
1456     */
1457    private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1458        $first = true;
1459        $minifier->addOutput( "{" );
1460        foreach ( $files as $fileName => $file ) {
1461            if ( $first ) {
1462                $first = false;
1463            } else {
1464                $minifier->addOutput( "," );
1465            }
1466            $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1467            $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1468        }
1469        $minifier->addOutput( "}" );
1470    }
1471
1472    /**
1473     * Add a package file to a MinifierState
1474     *
1475     * @param MinifierState $minifier
1476     * @param string $moduleName
1477     * @param string $sourceType
1478     * @param string|int $sourceIndex
1479     * @param array $file The expanded file info array
1480     */
1481    private function addFileContent( MinifierState $minifier,
1482        $moduleName, $sourceType, $sourceIndex, array $file
1483    ) {
1484        $isScript = ( $file['type'] ?? 'script' ) === 'script';
1485        /** @var FilePath|null $filePath */
1486        $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1487        if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1488            $url = $filePath->getRemotePath();
1489        } else {
1490            $ext = $isScript ? 'js' : 'json';
1491            $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1492                ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1493            $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1494        }
1495        $content = $file['content'];
1496        if ( $isScript ) {
1497            if ( $sourceType === 'packageFile' ) {
1498                // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1499                // $/jQuery are simply used as globals instead.
1500                // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1501                $minifier->addOutput( "function(require,module,exports){" );
1502                $minifier->addSourceFile( $url, $content, true );
1503                $minifier->ensureNewline();
1504                $minifier->addOutput( "}" );
1505            } else {
1506                $minifier->addSourceFile( $url, $content, true );
1507                $minifier->ensureNewline();
1508            }
1509        } else {
1510            $content = Html::encodeJsVar( $content, true );
1511            $minifier->addSourceFile( $url, $content, true );
1512        }
1513    }
1514
1515    /**
1516     * Combine a plainScripts array like [ [ 'content' => '...' ] ] into a
1517     * single string.
1518     *
1519     * @param array[] $plainScripts
1520     * @return string
1521     */
1522    private static function concatenatePlainScripts( $plainScripts ) {
1523        $s = '';
1524        foreach ( $plainScripts as $script ) {
1525            // Make the script safe to concatenate by making sure there is at least one
1526            // trailing new line at the end of the content (T29054, T162719)
1527            $s .= self::ensureNewline( $script['content'] );
1528        }
1529        return $s;
1530    }
1531
1532    /**
1533     * Add contents from a plainScripts array like [ [ 'content' => '...' ]
1534     * to a MinifierState
1535     *
1536     * @param MinifierState $minifier
1537     * @param string $moduleName
1538     * @param array[] $plainScripts
1539     */
1540    private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1541        foreach ( $plainScripts as $index => $file ) {
1542            $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1543        }
1544    }
1545
1546    /**
1547     * Determine whether an array of file info arrays has empty content
1548     *
1549     * @param array $infos
1550     * @return bool
1551     */
1552    private function isEmptyFileInfos( $infos ) {
1553        $len = 0;
1554        foreach ( $infos as $info ) {
1555            $len += strlen( $info['content'] ?? '' );
1556        }
1557        return $len === 0;
1558    }
1559
1560    /**
1561     * Combines an associative array mapping media type to CSS into a
1562     * single stylesheet with "@media" blocks.
1563     *
1564     * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s)
1565     * @return string[] CSS strings
1566     */
1567    public static function makeCombinedStyles( array $stylePairs ) {
1568        $out = [];
1569        foreach ( $stylePairs as $media => $styles ) {
1570            // FileModule::getStyle can return the styles as a string or an
1571            // array of strings. This is to allow separation in the front-end.
1572            $styles = (array)$styles;
1573            foreach ( $styles as $style ) {
1574                $style = trim( $style );
1575                // Don't output an empty "@media print { }" block (T42498)
1576                if ( $style === '' ) {
1577                    continue;
1578                }
1579                // Transform the media type based on request params and config
1580                // The way that this relies on $wgRequest to propagate request params is slightly evil
1581                $media = OutputPage::transformCssMedia( $media );
1582
1583                if ( $media === '' || $media == 'all' ) {
1584                    $out[] = $style;
1585                } elseif ( is_string( $media ) ) {
1586                    $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1587                }
1588                // else: skip
1589            }
1590        }
1591        return $out;
1592    }
1593
1594    /**
1595     * Wrapper around json_encode that avoids needless escapes,
1596     * and pretty-prints in debug mode.
1597     *
1598     * @param mixed $data
1599     * @return string|false JSON string, false on error
1600     */
1601    private static function encodeJsonForScript( $data ) {
1602        // Keep output as small as possible by disabling needless escape modes
1603        // that PHP uses by default.
1604        // However, while most module scripts are only served on HTTP responses
1605        // for JavaScript, some modules can also be embedded in the HTML as inline
1606        // scripts. This, and the fact that we sometimes need to export strings
1607        // containing user-generated content and labels that may genuinely contain
1608        // a sequences like "</script>", we need to encode either '/' or '<'.
1609        // By default PHP escapes '/'. Let's escape '<' instead which is less common
1610        // and allows URLs to mostly remain readable.
1611        $jsonFlags = JSON_UNESCAPED_SLASHES |
1612            JSON_UNESCAPED_UNICODE |
1613            JSON_HEX_TAG |
1614            JSON_HEX_AMP;
1615        if ( self::inDebugMode() ) {
1616            $jsonFlags |= JSON_PRETTY_PRINT;
1617        }
1618        return json_encode( $data, $jsonFlags );
1619    }
1620
1621    /**
1622     * Format a JS call to mw.loader.state()
1623     *
1624     * @internal For use by StartUpModule
1625     * @param Context $context
1626     * @param array<string,string> $states
1627     * @return string JavaScript code
1628     */
1629    public static function makeLoaderStateScript(
1630        Context $context, array $states
1631    ) {
1632        return 'mw.loader.state('
1633            // Silently ignore invalid UTF-8 injected via 'modules' query
1634            // Don't issue server-side warnings for client errors. (T331641)
1635            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1636            . @$context->encodeJson( $states )
1637            . ');';
1638    }
1639
1640    private static function isEmptyObject( stdClass $obj ) {
1641        foreach ( $obj as $value ) {
1642            return false;
1643        }
1644        return true;
1645    }
1646
1647    /**
1648     * Remove empty values from the end of an array.
1649     *
1650     * Values considered empty:
1651     *
1652     * - null
1653     * - []
1654     * - new HtmlJsCode( '{}' )
1655     * - new stdClass()
1656     * - (object)[]
1657     *
1658     * @param array &$array
1659     */
1660    private static function trimArray( array &$array ): void {
1661        $i = count( $array );
1662        while ( $i-- ) {
1663            if ( $array[$i] === null
1664                || $array[$i] === []
1665                || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1666                || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1667            ) {
1668                unset( $array[$i] );
1669            } else {
1670                break;
1671            }
1672        }
1673    }
1674
1675    /**
1676     * Format JS code which calls `mw.loader.register()` with the given parameters.
1677     *
1678     * @par Example
1679     * @code
1680     *
1681     *     ResourceLoader::makeLoaderRegisterScript( $context, [
1682     *        [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
1683     *        [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
1684     *        ...
1685     *     ] ):
1686     * @endcode
1687     *
1688     * @internal For use by StartUpModule only
1689     * @param Context $context
1690     * @param array[] $modules Array of module registration arrays, each containing
1691     *  - string: module name
1692     *  - string: module version
1693     *  - array|null: List of dependencies (optional)
1694     *  - string|null: Module group (optional)
1695     *  - string|null: Name of foreign module source, or 'local' (optional)
1696     *  - string|null: Script body of a skip function (optional)
1697     * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?string,4?:?string,5?:?string}> $modules
1698     * @return string JavaScript code
1699     */
1700    public static function makeLoaderRegisterScript(
1701        Context $context, array $modules
1702    ) {
1703        // Optimisation: Transform dependency names into indexes when possible
1704        // to produce smaller output. They are expanded by mw.loader.register on
1705        // the other end.
1706        $index = [];
1707        foreach ( $modules as $i => $module ) {
1708            // Build module name index
1709            $index[$module[0]] = $i;
1710        }
1711        foreach ( $modules as &$module ) {
1712            if ( isset( $module[2] ) ) {
1713                foreach ( $module[2] as &$dependency ) {
1714                    if ( isset( $index[$dependency] ) ) {
1715                        // Replace module name in dependency list with index
1716                        $dependency = $index[$dependency];
1717                    }
1718                }
1719            }
1720            self::trimArray( $module );
1721        }
1722
1723        return 'mw.loader.register('
1724            . $context->encodeJson( $modules )
1725            . ');';
1726    }
1727
1728    /**
1729     * Format JS code which calls `mw.loader.addSource()` with the given parameters.
1730     *
1731     *   - ResourceLoader::makeLoaderSourcesScript( $context,
1732     *         [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
1733     *     );
1734     *       Register sources with the given IDs and properties.
1735     *
1736     * @internal For use by StartUpModule only
1737     * @param Context $context
1738     * @param array<string,string> $sources
1739     * @return string JavaScript code
1740     */
1741    public static function makeLoaderSourcesScript(
1742        Context $context, array $sources
1743    ) {
1744        return 'mw.loader.addSource('
1745            . $context->encodeJson( $sources )
1746            . ');';
1747    }
1748
1749    /**
1750     * Wrap JavaScript code to run after the startup module.
1751     *
1752     * @param string $script JavaScript code
1753     * @return string JavaScript code
1754     */
1755    public static function makeLoaderConditionalScript( $script ) {
1756        // Adds a function to lazy-created RLQ
1757        return '(RLQ=window.RLQ||[]).push(function(){' .
1758            trim( $script ) . '});';
1759    }
1760
1761    /**
1762     * Wrap JavaScript code to run after a required module.
1763     *
1764     * @since 1.32
1765     * @param string|string[] $modules Module name(s)
1766     * @param string $script JavaScript code
1767     * @return string JavaScript code
1768     */
1769    public static function makeInlineCodeWithModule( $modules, $script ) {
1770        // Adds an array to lazy-created RLQ
1771        return '(RLQ=window.RLQ||[]).push(['
1772            . self::encodeJsonForScript( $modules ) . ','
1773            . 'function(){' . trim( $script ) . '}'
1774            . ']);';
1775    }
1776
1777    /**
1778     * Make an HTML script that runs given JS code after startup and base modules.
1779     *
1780     * The code will be wrapped in a closure, and it will be executed by ResourceLoader's
1781     * startup module if the client has adequate support for MediaWiki JavaScript code.
1782     *
1783     * @param string $script JavaScript code
1784     * @param string|null $nonce Unused
1785     * @return string|WrappedString HTML
1786     */
1787    public static function makeInlineScript( $script, $nonce = null ) {
1788        $js = self::makeLoaderConditionalScript( $script );
1789        return new WrappedString(
1790            Html::inlineScript( $js ),
1791            "<script>(RLQ=window.RLQ||[]).push(function(){",
1792            '});</script>'
1793        );
1794    }
1795
1796    /**
1797     * Return JS code which will set the MediaWiki configuration array to
1798     * the given value.
1799     *
1800     * @param array $configuration List of configuration values keyed by variable name
1801     * @return string JavaScript code
1802     * @throws LogicException
1803     */
1804    public static function makeConfigSetScript( array $configuration ) {
1805        $json = self::encodeJsonForScript( $configuration );
1806        if ( $json === false ) {
1807            $e = new LogicException(
1808                'JSON serialization of config data failed. ' .
1809                'This usually means the config data is not valid UTF-8.'
1810            );
1811            MWExceptionHandler::logException( $e );
1812            return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1813        }
1814        return "mw.config.set($json);";
1815    }
1816
1817    /**
1818     * Convert an array of module names to a packed query string.
1819     *
1820     * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
1821     * becomes `'foo.bar,baz|bar.baz,quux'`.
1822     *
1823     * This process is reversed by ResourceLoader::expandModuleNames().
1824     * See also mw.loader#buildModulesString() which is a port of this, used
1825     * on the client-side.
1826     *
1827     * @param string[] $modules List of module names (strings)
1828     * @return string Packed query string
1829     */
1830    public static function makePackedModulesString( array $modules ) {
1831        $moduleMap = []; // [ prefix => [ suffixes ] ]
1832        foreach ( $modules as $module ) {
1833            $pos = strrpos( $module, '.' );
1834            $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1835            $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1836            $moduleMap[$prefix][] = $suffix;
1837        }
1838
1839        $arr = [];
1840        foreach ( $moduleMap as $prefix => $suffixes ) {
1841            $p = $prefix === '' ? '' : $prefix . '.';
1842            $arr[] = $p . implode( ',', $suffixes );
1843        }
1844        return implode( '|', $arr );
1845    }
1846
1847    /**
1848     * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
1849     * an array of module names like `[ 'jquery.foo', 'jquery.bar',
1850     * 'jquery.ui.baz', 'jquery.ui.quux' ]`.
1851     *
1852     * This process is reversed by ResourceLoader::makePackedModulesString().
1853     *
1854     * @since 1.33
1855     * @param string $modules Packed module name list
1856     * @return string[] Array of module names
1857     */
1858    public static function expandModuleNames( $modules ) {
1859        $retval = [];
1860        $exploded = explode( '|', $modules );
1861        foreach ( $exploded as $group ) {
1862            if ( strpos( $group, ',' ) === false ) {
1863                // This is not a set of modules in foo.bar,baz notation
1864                // but a single module
1865                $retval[] = $group;
1866                continue;
1867            }
1868            // This is a set of modules in foo.bar,baz notation
1869            $pos = strrpos( $group, '.' );
1870            if ( $pos === false ) {
1871                // Prefixless modules, i.e. without dots
1872                $retval = array_merge( $retval, explode( ',', $group ) );
1873                continue;
1874            }
1875            // We have a prefix and a bunch of suffixes
1876            $prefix = substr( $group, 0, $pos ); // 'foo'
1877            $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1878            foreach ( $suffixes as $suffix ) {
1879                $retval[] = "$prefix.$suffix";
1880            }
1881        }
1882        return $retval;
1883    }
1884
1885    /**
1886     * Determine whether debug mode is on.
1887     *
1888     * Order of priority is:
1889     * - 1) Request parameter,
1890     * - 2) Cookie,
1891     * - 3) Site configuration.
1892     *
1893     * @return int
1894     */
1895    public static function inDebugMode() {
1896        if ( self::$debugMode === null ) {
1897            global $wgRequest;
1898
1899            $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1900                MainConfigNames::ResourceLoaderDebug );
1901            $str = $wgRequest->getRawVal( 'debug',
1902                $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' )
1903            );
1904            self::$debugMode = Context::debugFromString( $str );
1905        }
1906        return self::$debugMode;
1907    }
1908
1909    /**
1910     * Reset static members used for caching.
1911     *
1912     * Global state and $wgRequest are evil, but we're using it right
1913     * now and sometimes we need to be able to force ResourceLoader to
1914     * re-evaluate the context because it has changed (e.g. in the test suite).
1915     *
1916     * @internal For use by unit tests
1917     * @codeCoverageIgnore
1918     */
1919    public static function clearCache() {
1920        self::$debugMode = null;
1921    }
1922
1923    /**
1924     * Build a load.php URL
1925     *
1926     * @since 1.24
1927     * @param string $source Name of the ResourceLoader source
1928     * @param Context $context
1929     * @param array $extraQuery
1930     * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1931     */
1932    public function createLoaderURL( $source, Context $context,
1933        array $extraQuery = []
1934    ) {
1935        $query = self::createLoaderQuery( $context, $extraQuery );
1936        $script = $this->getLoadScript( $source );
1937
1938        return wfAppendQuery( $script, $query );
1939    }
1940
1941    /**
1942     * Helper for createLoaderURL()
1943     *
1944     * @since 1.24
1945     * @see makeLoaderQuery
1946     * @param Context $context
1947     * @param array $extraQuery
1948     * @return array
1949     */
1950    protected static function createLoaderQuery(
1951        Context $context, array $extraQuery = []
1952    ) {
1953        return self::makeLoaderQuery(
1954            $context->getModules(),
1955            $context->getLanguage(),
1956            $context->getSkin(),
1957            $context->getUser(),
1958            $context->getVersion(),
1959            $context->getDebug(),
1960            $context->getOnly(),
1961            $context->getRequest()->getBool( 'printable' ),
1962            null,
1963            $extraQuery
1964        );
1965    }
1966
1967    /**
1968     * Build a query array (array representation of query string) for load.php. Helper
1969     * function for createLoaderURL().
1970     *
1971     * @param string[] $modules
1972     * @param string $lang
1973     * @param string $skin
1974     * @param string|null $user
1975     * @param string|null $version
1976     * @param int $debug
1977     * @param string|null $only
1978     * @param bool $printable
1979     * @param bool|null $handheld Unused as of MW 1.38
1980     * @param array $extraQuery
1981     * @return array
1982     */
1983    public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1984        $version = null, $debug = Context::DEBUG_OFF, $only = null,
1985        $printable = false, $handheld = null, array $extraQuery = []
1986    ) {
1987        $query = [
1988            'modules' => self::makePackedModulesString( $modules ),
1989        ];
1990        // Keep urls short by omitting query parameters that
1991        // match the defaults assumed by Context.
1992        // Note: This relies on the defaults either being insignificant or forever constant,
1993        // as otherwise cached urls could change in meaning when the defaults change.
1994        if ( $lang !== Context::DEFAULT_LANG ) {
1995            $query['lang'] = $lang;
1996        }
1997        if ( $skin !== Context::DEFAULT_SKIN ) {
1998            $query['skin'] = $skin;
1999        }
2000        if ( $debug !== Context::DEBUG_OFF ) {
2001            $query['debug'] = strval( $debug );
2002        }
2003        if ( $user !== null ) {
2004            $query['user'] = $user;
2005        }
2006        if ( $version !== null ) {
2007            $query['version'] = $version;
2008        }
2009        if ( $only !== null ) {
2010            $query['only'] = $only;
2011        }
2012        if ( $printable ) {
2013            $query['printable'] = 1;
2014        }
2015        foreach ( $extraQuery as $name => $value ) {
2016            $query[$name] = $value;
2017        }
2018
2019        // Make queries uniform in order
2020        ksort( $query );
2021        return $query;
2022    }
2023
2024    /**
2025     * Check a module name for validity.
2026     *
2027     * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
2028     * at most 255 bytes.
2029     *
2030     * @param string $moduleName Module name to check
2031     * @return bool Whether $moduleName is a valid module name
2032     */
2033    public static function isValidModuleName( $moduleName ) {
2034        $len = strlen( $moduleName );
2035        return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
2036    }
2037
2038    /**
2039     * Return a LESS compiler that is set up for use with MediaWiki.
2040     *
2041     * @since 1.27
2042     * @param array $vars Associative array of variables that should be used
2043     *  for compilation. Since 1.32, this method no longer automatically includes
2044     *  global LESS vars from ResourceLoader::getLessVars (T191937).
2045     * @param array $importDirs Additional directories to look in for @import (since 1.36)
2046     * @return Less_Parser
2047     */
2048    public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2049        global $IP;
2050        // When called from the installer, it is possible that a required PHP extension
2051        // is missing (at least for now; see T49564). If this is the case, throw an
2052        // exception (caught by the installer) to prevent a fatal error later on.
2053        if ( !class_exists( Less_Parser::class ) ) {
2054            throw new RuntimeException( 'MediaWiki requires the less.php parser' );
2055        }
2056
2057        $importDirs[] = "$IP/resources/src/mediawiki.less";
2058
2059        $parser = new Less_Parser;
2060        $parser->ModifyVars( $vars );
2061        $parser->SetOption( 'relativeUrls', false );
2062
2063        // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2064        $formattedImportDirs = array_fill_keys( $importDirs, '' );
2065        // Add a callback to the import dirs array for path remapping
2066        $formattedImportDirs[] = static function ( $path ) {
2067            global $IP;
2068            $importMap = [
2069                '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
2070                'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
2071                '@wikimedia/codex-design-tokens/' => /** @return never */ static function ( $unused_path ) {
2072                    throw new RuntimeException(
2073                        'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2074                        "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2075                    );
2076                }
2077            ];
2078            foreach ( $importMap as $importPath => $substPath ) {
2079                if ( str_starts_with( $path, $importPath ) ) {
2080                    $restOfPath = substr( $path, strlen( $importPath ) );
2081                    if ( is_callable( $substPath ) ) {
2082                        $resolvedPath = call_user_func( $substPath, $restOfPath );
2083                    } else {
2084                        $filePath = $substPath . $restOfPath;
2085
2086                        $resolvedPath = null;
2087                        if ( file_exists( $filePath ) ) {
2088                            $resolvedPath = $filePath;
2089                        } elseif ( file_exists( "$filePath.less" ) ) {
2090                            $resolvedPath = "$filePath.less";
2091                        }
2092                    }
2093
2094                    if ( $resolvedPath !== null ) {
2095                        return [
2096                            Less_Environment::normalizePath( $resolvedPath ),
2097                            Less_Environment::normalizePath( dirname( $path ) )
2098                        ];
2099                    } else {
2100                        break;
2101                    }
2102                }
2103            }
2104            return [ null, null ];
2105        };
2106        $parser->SetImportDirs( $formattedImportDirs );
2107
2108        return $parser;
2109    }
2110
2111    /**
2112     * Resolve a possibly relative URL against a base URL.
2113     *
2114     * The base URL must have a server and should have a protocol.
2115     * A protocol-relative base expands to HTTPS.
2116     *
2117     * This is a standalone version of MediaWiki's UrlUtils::expand (T32956).
2118     *
2119     * @internal For use by core ResourceLoader classes only
2120     * @param string $base
2121     * @param string $url
2122     * @return string URL
2123     */
2124    public function expandUrl( string $base, string $url ): string {
2125        // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2126        $isProtoRelative = strpos( $base, '//' ) === 0;
2127        if ( $isProtoRelative ) {
2128            $base = "https:$base";
2129        }
2130        // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2131        $baseUrl = new Net_URL2( $base );
2132        $ret = $baseUrl->resolve( $url );
2133        if ( $isProtoRelative ) {
2134            $ret->setScheme( false );
2135        }
2136        return $ret->getURL();
2137    }
2138
2139    /**
2140     * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
2141     *
2142     * Available filters are:
2143     *
2144     *    - minify-js
2145     *    - minify-css
2146     *
2147     * If $data is empty, only contains whitespace or the filter was unknown,
2148     * $data is returned unmodified.
2149     *
2150     * @param string $filter Name of filter to run
2151     * @param string $data Text to filter, such as JavaScript or CSS text
2152     * @param array<string,bool> $options Keys:
2153     *  - (bool) cache: Whether to allow caching this data. Default: true.
2154     * @return string Filtered data or unfiltered data
2155     */
2156    public static function filter( $filter, $data, array $options = [] ) {
2157        if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2158            return $data;
2159        }
2160
2161        if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2162            return self::applyFilter( $filter, $data ) ?? $data;
2163        }
2164
2165        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2166        $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
2167
2168        $key = $cache->makeGlobalKey(
2169            'resourceloader-filter',
2170            $filter,
2171            self::CACHE_VERSION,
2172            md5( $data )
2173        );
2174
2175        $status = 'hit';
2176        $incKey = "resourceloader_cache.$filter.$status";
2177        $result = $cache->getWithSetCallback(
2178            $key,
2179            BagOStuff::TTL_DAY,
2180            static function () use ( $filter, $data, &$incKey, &$status ) {
2181                $status = 'miss';
2182                $incKey = "resourceloader_cache.$filter.$status";
2183                return self::applyFilter( $filter, $data );
2184            }
2185        );
2186        $statsFactory->getCounter( 'resourceloader_cache_total' )
2187            ->setLabel( 'type', $filter )
2188            ->setLabel( 'status', $status )
2189            ->copyToStatsdAt( [ $incKey ] )
2190            ->increment();
2191
2192        // Use $data on cache failure
2193        return $result ?? $data;
2194    }
2195
2196    /**
2197     * @param string $filter
2198     * @param string $data
2199     * @return string|null
2200     */
2201    private static function applyFilter( $filter, $data ) {
2202        $data = trim( $data );
2203        if ( $data ) {
2204            try {
2205                $data = ( $filter === 'minify-css' )
2206                    ? CSSMin::minify( $data )
2207                    : JavaScriptMinifier::minify( $data );
2208            } catch ( TimeoutException $e ) {
2209                throw $e;
2210            } catch ( Exception $e ) {
2211                MWExceptionHandler::logException( $e );
2212                return null;
2213            }
2214        }
2215        return $data;
2216    }
2217
2218    /**
2219     * Get user default options to expose to JavaScript on all pages via `mw.user.options`.
2220     *
2221     * @internal Exposed for use from Resources.php
2222     *
2223     * @param Context $context
2224     * @param HookContainer $hookContainer
2225     * @param UserOptionsLookup $userOptionsLookup
2226     *
2227     * @return array
2228     */
2229    public static function getUserDefaults(
2230        Context $context,
2231        HookContainer $hookContainer,
2232        UserOptionsLookup $userOptionsLookup
2233    ): array {
2234        $defaultOptions = $userOptionsLookup->getDefaultOptions();
2235        $keysToExclude = [];
2236        $hookRunner = new HookRunner( $hookContainer );
2237        $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2238        foreach ( $keysToExclude as $excludedKey ) {
2239            unset( $defaultOptions[ $excludedKey ] );
2240        }
2241        return $defaultOptions;
2242    }
2243
2244    /**
2245     * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
2246     *
2247     * @internal Exposed for use from Resources.php
2248     * @param Context $context
2249     * @param Config $conf
2250     * @return array
2251     */
2252    public static function getSiteConfigSettings(
2253        Context $context, Config $conf
2254    ): array {
2255        $services = MediaWikiServices::getInstance();
2256        // Namespace related preparation
2257        // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2258        // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2259        $contLang = $services->getContentLanguage();
2260        $namespaceIds = $contLang->getNamespaceIds();
2261        $caseSensitiveNamespaces = [];
2262        $nsInfo = $services->getNamespaceInfo();
2263        foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2264            $namespaceIds[$contLang->lc( $name )] = $index;
2265            if ( !$nsInfo->isCapitalized( $index ) ) {
2266                $caseSensitiveNamespaces[] = $index;
2267            }
2268        }
2269
2270        $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2271
2272        // Build list of variables
2273        $skin = $context->getSkin();
2274
2275        // Start of supported and stable config vars (for use by extensions/gadgets).
2276        $vars = [
2277            'debug' => $context->getDebug(),
2278            'skin' => $skin,
2279            'stylepath' => $conf->get( MainConfigNames::StylePath ),
2280            'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2281            'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2282            'wgScript' => $conf->get( MainConfigNames::Script ),
2283            'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2284            'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2285            'wgServer' => $conf->get( MainConfigNames::Server ),
2286            'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2287            'wgUserLanguage' => $context->getLanguage(),
2288            'wgContentLanguage' => $contLang->getCode(),
2289            'wgVersion' => MW_VERSION,
2290            'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2291            'wgNamespaceIds' => $namespaceIds,
2292            'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2293            'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2294            'wgDBname' => $conf->get( MainConfigNames::DBname ),
2295            'wgWikiID' => WikiMap::getCurrentWikiId(),
2296            'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2297            'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2298            'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2299        ];
2300        // End of stable config vars.
2301
2302        // Internal variables for use by MediaWiki core and/or ResourceLoader.
2303        $vars += [
2304            // @internal For mediawiki.widgets
2305            'wgUrlProtocols' => wfUrlProtocols(),
2306            // @internal For mediawiki.page.watch
2307            // Force object to avoid "empty" associative array from
2308            // becoming [] instead of {} in JS (T36604)
2309            'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2310            // @internal For mediawiki.language
2311            'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2312            // @internal For mediawiki.Title
2313            'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2314            'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2315            'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2316        ];
2317
2318        ( new HookRunner( $services->getHookContainer() ) )
2319            ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2320
2321        return $vars;
2322    }
2323
2324    /**
2325     * @internal For testing
2326     * @return array
2327     */
2328    public function getErrors() {
2329        return $this->errors;
2330    }
2331}