Completed
Branch master (62f6c6)
by
unknown
21:31
created

ResourceLoader   F

Complexity

Total Complexity 211

Size/Duplication

Total Lines 1593
Duplicated Lines 1.88 %

Coupling/Cohesion

Components 2
Dependencies 29

Importance

Changes 0
Metric Value
dl 30
loc 1593
rs 0.5217
c 0
b 0
f 0
wmc 211
lcom 2
cbo 29

52 Methods

Rating   Name   Duplication   Size   Complexity  
C preloadModuleInfo() 0 50 10
B filter() 0 34 6
A applyFilter() 0 14 4
B __construct() 0 30 4
A getConfig() 0 3 1
A setLogger() 0 3 1
A getLogger() 0 3 1
A getMessageBlobStore() 0 3 1
A setMessageBlobStore() 0 3 1
C register() 0 74 14
B registerTestModules() 0 37 4
B addSource() 0 30 6
A getModuleNames() 0 3 1
A getTestModuleNames() 0 12 4
A isModuleRegistered() 0 3 1
B getModule() 0 29 5
A isFileModule() 0 10 4
A getSources() 0 3 1
A getLoadScript() 0 6 2
A makeHash() 0 6 1
A getCombinedVersion() 0 9 2
F respond() 0 118 20
C sendResponseHeaders() 0 40 7
B tryRespondNotModified() 0 24 4
C tryRespondFromFileCache() 0 44 7
A makeComment() 0 4 1
A formatException() 0 3 1
A formatExceptionNoComment() 0 9 2
F makeModuleResponse() 0 115 28
A getModulesByMessage() 0 10 3
B makeLoaderImplementScript() 0 32 6
A makeMessageSetScript() 0 7 1
C makeCombinedStyles() 0 26 7
A makeLoaderStateScript() 15 15 2
A makeCustomLoaderScript() 0 10 1
A isEmptyObject() 0 6 2
B trimArray() 0 14 8
C makeLoaderRegisterScript() 0 39 7
A makeLoaderSourcesScript() 15 15 2
A makeLoaderConditionalScript() 0 4 1
A makeInlineScript() 0 8 1
A makeConfigSetScript() 0 7 1
B makePackedModulesString() 0 17 6
A inDebugMode() 0 9 2
A clearCache() 0 3 1
A createLoaderURL() 0 8 1
A makeLoaderURL() 0 12 1
A createLoaderQuery() 0 14 1
C makeLoaderQuery() 0 31 7
A isValidModuleName() 0 3 1
A getLessCompiler() 0 18 3
A getLessVars() 0 8 2

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like ResourceLoader often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ResourceLoader, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Base class for resource loading system.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @author Roan Kattouw
22
 * @author Trevor Parscal
23
 */
24
25
use Psr\Log\LoggerAwareInterface;
26
use Psr\Log\LoggerInterface;
27
use Psr\Log\NullLogger;
28
use WrappedString\WrappedString;
29
30
/**
31
 * Dynamic JavaScript and CSS resource loading system.
32
 *
33
 * Most of the documentation is on the MediaWiki documentation wiki starting at:
34
 *    https://www.mediawiki.org/wiki/ResourceLoader
35
 */
36
class ResourceLoader implements LoggerAwareInterface {
37
	/** @var int */
38
	protected static $filterCacheVersion = 7;
39
40
	/** @var bool */
41
	protected static $debugMode = null;
42
43
	/** @var array */
44
	private $lessVars = null;
45
46
	/**
47
	 * Module name/ResourceLoaderModule object pairs
48
	 * @var array
49
	 */
50
	protected $modules = [];
51
52
	/**
53
	 * Associative array mapping module name to info associative array
54
	 * @var array
55
	 */
56
	protected $moduleInfos = [];
57
58
	/** @var Config $config */
59
	private $config;
60
61
	/**
62
	 * Associative array mapping framework ids to a list of names of test suite modules
63
	 * like array( 'qunit' => array( 'mediawiki.tests.qunit.suites', 'ext.foo.tests', .. ), .. )
64
	 * @var array
65
	 */
66
	protected $testModuleNames = [];
67
68
	/**
69
	 * E.g. array( 'source-id' => 'http://.../load.php' )
70
	 * @var array
71
	 */
72
	protected $sources = [];
73
74
	/**
75
	 * Errors accumulated during current respond() call.
76
	 * @var array
77
	 */
78
	protected $errors = [];
79
80
	/**
81
	 * @var MessageBlobStore
82
	 */
83
	protected $blobStore;
84
85
	/**
86
	 * @var LoggerInterface
87
	 */
88
	private $logger;
89
90
	/** @var string JavaScript / CSS pragma to disable minification. **/
91
	const FILTER_NOMIN = '/*@nomin*/';
92
93
	/**
94
	 * Load information stored in the database about modules.
95
	 *
96
	 * This method grabs modules dependencies from the database and updates modules
97
	 * objects.
98
	 *
99
	 * This is not inside the module code because it is much faster to
100
	 * request all of the information at once than it is to have each module
101
	 * requests its own information. This sacrifice of modularity yields a substantial
102
	 * performance improvement.
103
	 *
104
	 * @param array $moduleNames List of module names to preload information for
105
	 * @param ResourceLoaderContext $context Context to load the information within
106
	 */
107
	public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) {
108
		if ( !$moduleNames ) {
109
			// Or else Database*::select() will explode, plus it's cheaper!
110
			return;
111
		}
112
		$dbr = wfGetDB( DB_SLAVE );
113
		$skin = $context->getSkin();
114
		$lang = $context->getLanguage();
115
116
		// Batched version of ResourceLoaderModule::getFileDependencies
117
		$vary = "$skin|$lang";
118
		$res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [
119
				'md_module' => $moduleNames,
120
				'md_skin' => $vary,
121
			], __METHOD__
122
		);
123
124
		// Prime in-object cache for file dependencies
125
		$modulesWithDeps = [];
126
		foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
127
			$module = $this->getModule( $row->md_module );
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $module is correct as $this->getModule($row->md_module) (which targets ResourceLoader::getModule()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
128
			if ( $module ) {
129
				$module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths(
130
					FormatJson::decode( $row->md_deps, true )
131
				) );
132
				$modulesWithDeps[] = $row->md_module;
133
			}
134
		}
135
		// Register the absence of a dependency row too
136
		foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) {
137
			$module = $this->getModule( $name );
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $module is correct as $this->getModule($name) (which targets ResourceLoader::getModule()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
138
			if ( $module ) {
139
				$this->getModule( $name )->setFileDependencies( $context, [] );
140
			}
141
		}
142
143
		// Prime in-object cache for message blobs for modules with messages
144
		$modules = [];
145
		foreach ( $moduleNames as $name ) {
146
			$module = $this->getModule( $name );
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $module is correct as $this->getModule($name) (which targets ResourceLoader::getModule()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
147
			if ( $module && $module->getMessages() ) {
148
				$modules[$name] = $module;
149
			}
150
		}
151
		$store = $this->getMessageBlobStore();
152
		$blobs = $store->getBlobs( $modules, $lang );
153
		foreach ( $blobs as $name => $blob ) {
154
			$modules[$name]->setMessageBlob( $blob, $lang );
155
		}
156
	}
157
158
	/**
159
	 * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
160
	 *
161
	 * Available filters are:
162
	 *
163
	 *    - minify-js \see JavaScriptMinifier::minify
164
	 *    - minify-css \see CSSMin::minify
165
	 *
166
	 * If $data is empty, only contains whitespace or the filter was unknown,
167
	 * $data is returned unmodified.
168
	 *
169
	 * @param string $filter Name of filter to run
170
	 * @param string $data Text to filter, such as JavaScript or CSS text
171
	 * @param array $options Keys:
172
	 *  - (bool) cache: Whether to allow caching this data. Default: true.
173
	 * @return string Filtered data, or a comment containing an error message
174
	 */
175
	public static function filter( $filter, $data, array $options = [] ) {
176
		if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !== false ) {
177
			return $data;
178
		}
179
180
		if ( isset( $options['cache'] ) && $options['cache'] === false ) {
181
			return self::applyFilter( $filter, $data );
182
		}
183
184
		$stats = RequestContext::getMain()->getStats();
0 ignored issues
show
Deprecated Code introduced by
The method RequestContext::getStats() has been deprecated with message: since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
185
		$cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
186
187
		$key = $cache->makeGlobalKey(
188
			'resourceloader',
189
			'filter',
190
			$filter,
191
			self::$filterCacheVersion, md5( $data )
192
		);
193
194
		$result = $cache->get( $key );
195
		if ( $result === false ) {
196
			$stats->increment( "resourceloader_cache.$filter.miss" );
197
			$result = self::applyFilter( $filter, $data );
198
			$cache->set( $key, $result, 24 * 3600 );
199
		} else {
200
			$stats->increment( "resourceloader_cache.$filter.hit" );
201
		}
202
		if ( $result === null ) {
203
			// Cached failure
204
			$result = $data;
205
		}
206
207
		return $result;
208
	}
209
210
	private static function applyFilter( $filter, $data ) {
211
		$data = trim( $data );
212
		if ( $data ) {
213
			try {
214
				$data = ( $filter === 'minify-css' )
215
					? CSSMin::minify( $data )
216
					: JavaScriptMinifier::minify( $data );
217
			} catch ( Exception $e ) {
218
				MWExceptionHandler::logException( $e );
219
				return null;
220
			}
221
		}
222
		return $data;
223
	}
224
225
	/* Methods */
226
227
	/**
228
	 * Register core modules and runs registration hooks.
229
	 * @param Config $config [optional]
230
	 * @param LoggerInterface $logger [optional]
231
	 */
232
	public function __construct( Config $config = null, LoggerInterface $logger = null ) {
233
		global $IP;
234
235
		$this->logger = $logger ?: new NullLogger();
236
237
		if ( !$config ) {
238
			$this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
239
			$config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
0 ignored issues
show
Deprecated Code introduced by
The method ConfigFactory::getDefaultInstance() has been deprecated with message: since 1.27, use MediaWikiServices::getConfigFactory() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
240
		}
241
		$this->config = $config;
242
243
		// Add 'local' source first
244
		$this->addSource( 'local', wfScript( 'load' ) );
245
246
		// Add other sources
247
		$this->addSource( $config->get( 'ResourceLoaderSources' ) );
248
249
		// Register core modules
250
		$this->register( include "$IP/resources/Resources.php" );
251
		$this->register( include "$IP/resources/ResourcesOOUI.php" );
252
		// Register extension modules
253
		$this->register( $config->get( 'ResourceModules' ) );
254
		Hooks::run( 'ResourceLoaderRegisterModules', [ &$this ] );
255
256
		if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
257
			$this->registerTestModules();
258
		}
259
260
		$this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
261
	}
262
263
	/**
264
	 * @return Config
265
	 */
266
	public function getConfig() {
267
		return $this->config;
268
	}
269
270
	/**
271
	 * @since 1.26
272
	 * @param LoggerInterface $logger
273
	 */
274
	public function setLogger( LoggerInterface $logger ) {
275
		$this->logger = $logger;
276
	}
277
278
	/**
279
	 * @since 1.27
280
	 * @return LoggerInterface
281
	 */
282
	public function getLogger() {
283
		return $this->logger;
284
	}
285
286
	/**
287
	 * @since 1.26
288
	 * @return MessageBlobStore
289
	 */
290
	public function getMessageBlobStore() {
291
		return $this->blobStore;
292
	}
293
294
	/**
295
	 * @since 1.25
296
	 * @param MessageBlobStore $blobStore
297
	 */
298
	public function setMessageBlobStore( MessageBlobStore $blobStore ) {
299
		$this->blobStore = $blobStore;
300
	}
301
302
	/**
303
	 * Register a module with the ResourceLoader system.
304
	 *
305
	 * @param mixed $name Name of module as a string or List of name/object pairs as an array
306
	 * @param array $info Module info array. For backwards compatibility with 1.17alpha,
307
	 *   this may also be a ResourceLoaderModule object. Optional when using
308
	 *   multiple-registration calling style.
309
	 * @throws MWException If a duplicate module registration is attempted
310
	 * @throws MWException If a module name contains illegal characters (pipes or commas)
311
	 * @throws MWException If something other than a ResourceLoaderModule is being registered
312
	 * @return bool False if there were any errors, in which case one or more modules were
313
	 *   not registered
314
	 */
315
	public function register( $name, $info = null ) {
316
317
		// Allow multiple modules to be registered in one call
318
		$registrations = is_array( $name ) ? $name : [ $name => $info ];
319
		foreach ( $registrations as $name => $info ) {
320
			// Warn on duplicate registrations
321
			if ( isset( $this->moduleInfos[$name] ) ) {
322
				// A module has already been registered by this name
323
				$this->logger->warning(
324
					'ResourceLoader duplicate registration warning. ' .
325
					'Another module has already been registered as ' . $name
326
				);
327
			}
328
329
			// Check $name for validity
330
			if ( !self::isValidModuleName( $name ) ) {
331
				throw new MWException( "ResourceLoader module name '$name' is invalid, "
332
					. "see ResourceLoader::isValidModuleName()" );
333
			}
334
335
			// Attach module
336
			if ( $info instanceof ResourceLoaderModule ) {
337
				$this->moduleInfos[$name] = [ 'object' => $info ];
338
				$info->setName( $name );
339
				$this->modules[$name] = $info;
340
			} elseif ( is_array( $info ) ) {
341
				// New calling convention
342
				$this->moduleInfos[$name] = $info;
343
			} else {
344
				throw new MWException(
345
					'ResourceLoader module info type error for module \'' . $name .
346
					'\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
347
				);
348
			}
349
350
			// Last-minute changes
351
352
			// Apply custom skin-defined styles to existing modules.
353
			if ( $this->isFileModule( $name ) ) {
354
				foreach ( $this->config->get( 'ResourceModuleSkinStyles' ) as $skinName => $skinStyles ) {
355
					// If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
356
					if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
357
						continue;
358
					}
359
360
					// If $name is preceded with a '+', the defined style files will be added to 'default'
361
					// skinStyles, otherwise 'default' will be ignored as it normally would be.
362
					if ( isset( $skinStyles[$name] ) ) {
363
						$paths = (array)$skinStyles[$name];
364
						$styleFiles = [];
365
					} elseif ( isset( $skinStyles['+' . $name] ) ) {
366
						$paths = (array)$skinStyles['+' . $name];
367
						$styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
368
							(array)$this->moduleInfos[$name]['skinStyles']['default'] :
369
							[];
370
					} else {
371
						continue;
372
					}
373
374
					// Add new file paths, remapping them to refer to our directories and not use settings
375
					// from the module we're modifying, which come from the base definition.
376
					list( $localBasePath, $remoteBasePath ) =
377
						ResourceLoaderFileModule::extractBasePaths( $skinStyles );
378
379
					foreach ( $paths as $path ) {
380
						$styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
381
					}
382
383
					$this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
384
				}
385
			}
386
		}
387
388
	}
389
390
	/**
391
	 */
392
	public function registerTestModules() {
393
		global $IP;
394
395
		if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
396
			throw new MWException( 'Attempt to register JavaScript test modules '
397
				. 'but <code>$wgEnableJavaScriptTest</code> is false. '
398
				. 'Edit your <code>LocalSettings.php</code> to enable it.' );
399
		}
400
401
		// Get core test suites
402
		$testModules = [];
403
		$testModules['qunit'] = [];
404
		// Get other test suites (e.g. from extensions)
405
		Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$this ] );
406
407
		// Add the testrunner (which configures QUnit) to the dependencies.
408
		// Since it must be ready before any of the test suites are executed.
409
		foreach ( $testModules['qunit'] as &$module ) {
410
			// Make sure all test modules are top-loading so that when QUnit starts
411
			// on document-ready, it will run once and finish. If some tests arrive
412
			// later (possibly after QUnit has already finished) they will be ignored.
413
			$module['position'] = 'top';
414
			$module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
415
		}
416
417
		$testModules['qunit'] =
418
			( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
419
420
		foreach ( $testModules as $id => $names ) {
421
			// Register test modules
422
			$this->register( $testModules[$id] );
423
424
			// Keep track of their names so that they can be loaded together
425
			$this->testModuleNames[$id] = array_keys( $testModules[$id] );
426
		}
427
428
	}
429
430
	/**
431
	 * Add a foreign source of modules.
432
	 *
433
	 * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
434
	 *
435
	 * @param array|string $id Source ID (string), or array( id1 => loadUrl, id2 => loadUrl, ... )
436
	 * @param string|array $loadUrl load.php url (string), or array with loadUrl key for
437
	 *  backwards-compatibility.
438
	 * @throws MWException
439
	 */
440
	public function addSource( $id, $loadUrl = null ) {
441
		// Allow multiple sources to be registered in one call
442
		if ( is_array( $id ) ) {
443
			foreach ( $id as $key => $value ) {
444
				$this->addSource( $key, $value );
445
			}
446
			return;
447
		}
448
449
		// Disallow duplicates
450
		if ( isset( $this->sources[$id] ) ) {
451
			throw new MWException(
452
				'ResourceLoader duplicate source addition error. ' .
453
				'Another source has already been registered as ' . $id
454
			);
455
		}
456
457
		// Pre 1.24 backwards-compatibility
458
		if ( is_array( $loadUrl ) ) {
459
			if ( !isset( $loadUrl['loadScript'] ) ) {
460
				throw new MWException(
461
					__METHOD__ . ' was passed an array with no "loadScript" key.'
462
				);
463
			}
464
465
			$loadUrl = $loadUrl['loadScript'];
466
		}
467
468
		$this->sources[$id] = $loadUrl;
469
	}
470
471
	/**
472
	 * Get a list of module names.
473
	 *
474
	 * @return array List of module names
475
	 */
476
	public function getModuleNames() {
477
		return array_keys( $this->moduleInfos );
478
	}
479
480
	/**
481
	 * Get a list of test module names for one (or all) frameworks.
482
	 *
483
	 * If the given framework id is unknkown, or if the in-object variable is not an array,
484
	 * then it will return an empty array.
485
	 *
486
	 * @param string $framework Get only the test module names for one
487
	 *   particular framework (optional)
488
	 * @return array
489
	 */
490
	public function getTestModuleNames( $framework = 'all' ) {
491
		/** @todo api siteinfo prop testmodulenames modulenames */
492
		if ( $framework == 'all' ) {
493
			return $this->testModuleNames;
494
		} elseif ( isset( $this->testModuleNames[$framework] )
495
			&& is_array( $this->testModuleNames[$framework] )
496
		) {
497
			return $this->testModuleNames[$framework];
498
		} else {
499
			return [];
500
		}
501
	}
502
503
	/**
504
	 * Check whether a ResourceLoader module is registered
505
	 *
506
	 * @since 1.25
507
	 * @param string $name
508
	 * @return bool
509
	 */
510
	public function isModuleRegistered( $name ) {
511
		return isset( $this->moduleInfos[$name] );
512
	}
513
514
	/**
515
	 * Get the ResourceLoaderModule object for a given module name.
516
	 *
517
	 * If an array of module parameters exists but a ResourceLoaderModule object has not
518
	 * yet been instantiated, this method will instantiate and cache that object such that
519
	 * subsequent calls simply return the same object.
520
	 *
521
	 * @param string $name Module name
522
	 * @return ResourceLoaderModule|null If module has been registered, return a
523
	 *  ResourceLoaderModule instance. Otherwise, return null.
524
	 */
525
	public function getModule( $name ) {
526
		if ( !isset( $this->modules[$name] ) ) {
527
			if ( !isset( $this->moduleInfos[$name] ) ) {
528
				// No such module
529
				return null;
530
			}
531
			// Construct the requested object
532
			$info = $this->moduleInfos[$name];
533
			/** @var ResourceLoaderModule $object */
534
			if ( isset( $info['object'] ) ) {
535
				// Object given in info array
536
				$object = $info['object'];
537
			} else {
538
				if ( !isset( $info['class'] ) ) {
539
					$class = 'ResourceLoaderFileModule';
540
				} else {
541
					$class = $info['class'];
542
				}
543
				/** @var ResourceLoaderModule $object */
544
				$object = new $class( $info );
545
				$object->setConfig( $this->getConfig() );
546
				$object->setLogger( $this->logger );
547
			}
548
			$object->setName( $name );
549
			$this->modules[$name] = $object;
550
		}
551
552
		return $this->modules[$name];
553
	}
554
555
	/**
556
	 * Return whether the definition of a module corresponds to a simple ResourceLoaderFileModule.
557
	 *
558
	 * @param string $name Module name
559
	 * @return bool
560
	 */
561
	protected function isFileModule( $name ) {
562
		if ( !isset( $this->moduleInfos[$name] ) ) {
563
			return false;
564
		}
565
		$info = $this->moduleInfos[$name];
566
		if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
567
			return false;
568
		}
569
		return true;
570
	}
571
572
	/**
573
	 * Get the list of sources.
574
	 *
575
	 * @return array Like array( id => load.php url, .. )
576
	 */
577
	public function getSources() {
578
		return $this->sources;
579
	}
580
581
	/**
582
	 * Get the URL to the load.php endpoint for the given
583
	 * ResourceLoader source
584
	 *
585
	 * @since 1.24
586
	 * @param string $source
587
	 * @throws MWException On an invalid $source name
588
	 * @return string
589
	 */
590
	public function getLoadScript( $source ) {
591
		if ( !isset( $this->sources[$source] ) ) {
592
			throw new MWException( "The $source source was never registered in ResourceLoader." );
593
		}
594
		return $this->sources[$source];
595
	}
596
597
	/**
598
	 * @since 1.26
599
	 * @param string $value
600
	 * @return string Hash
601
	 */
602
	public static function makeHash( $value ) {
603
		// Use base64 to output more entropy in a more compact string (default hex is only base16).
604
		// The first 8 chars of a base64 encoded digest represent the same binary as
605
		// the first 12 chars of a hex encoded digest.
606
		return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
607
	}
608
609
	/**
610
	 * Helper method to get and combine versions of multiple modules.
611
	 *
612
	 * @since 1.26
613
	 * @param ResourceLoaderContext $context
614
	 * @param array $modules List of ResourceLoaderModule objects
615
	 * @return string Hash
616
	 */
617
	public function getCombinedVersion( ResourceLoaderContext $context, array $modules ) {
618
		if ( !$modules ) {
619
			return '';
620
		}
621
		$hashes = array_map( function ( $module ) use ( $context ) {
622
			return $this->getModule( $module )->getVersionHash( $context );
623
		}, $modules );
624
		return self::makeHash( implode( $hashes ) );
625
	}
626
627
	/**
628
	 * Output a response to a load request, including the content-type header.
629
	 *
630
	 * @param ResourceLoaderContext $context Context in which a response should be formed
631
	 */
632
	public function respond( ResourceLoaderContext $context ) {
633
		// Buffer output to catch warnings. Normally we'd use ob_clean() on the
634
		// top-level output buffer to clear warnings, but that breaks when ob_gzhandler
635
		// is used: ob_clean() will clear the GZIP header in that case and it won't come
636
		// back for subsequent output, resulting in invalid GZIP. So we have to wrap
637
		// the whole thing in our own output buffer to be sure the active buffer
638
		// doesn't use ob_gzhandler.
639
		// See http://bugs.php.net/bug.php?id=36514
640
		ob_start();
641
642
		// Find out which modules are missing and instantiate the others
643
		$modules = [];
644
		$missing = [];
645
		foreach ( $context->getModules() as $name ) {
646
			$module = $this->getModule( $name );
647
			if ( $module ) {
648
				// Do not allow private modules to be loaded from the web.
649
				// This is a security issue, see bug 34907.
650
				if ( $module->getGroup() === 'private' ) {
651
					$this->logger->debug( "Request for private module '$name' denied" );
652
					$this->errors[] = "Cannot show private module \"$name\"";
653
					continue;
654
				}
655
				$modules[$name] = $module;
656
			} else {
657
				$missing[] = $name;
658
			}
659
		}
660
661
		try {
662
			// Preload for getCombinedVersion() and for batch makeModuleResponse()
663
			$this->preloadModuleInfo( array_keys( $modules ), $context );
664
		} catch ( Exception $e ) {
665
			MWExceptionHandler::logException( $e );
666
			$this->logger->warning( 'Preloading module info failed: {exception}', [
667
				'exception' => $e
668
			] );
669
			$this->errors[] = self::formatExceptionNoComment( $e );
670
		}
671
672
		// Combine versions to propagate cache invalidation
673
		$versionHash = '';
674
		try {
675
			$versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
676
		} catch ( Exception $e ) {
677
			MWExceptionHandler::logException( $e );
678
			$this->logger->warning( 'Calculating version hash failed: {exception}', [
679
				'exception' => $e
680
			] );
681
			$this->errors[] = self::formatExceptionNoComment( $e );
682
		}
683
684
		// See RFC 2616 § 3.11 Entity Tags
685
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
686
		$etag = 'W/"' . $versionHash . '"';
687
688
		// Try the client-side cache first
689
		if ( $this->tryRespondNotModified( $context, $etag ) ) {
690
			return; // output handled (buffers cleared)
691
		}
692
693
		// Use file cache if enabled and available...
694
		if ( $this->config->get( 'UseFileCache' ) ) {
695
			$fileCache = ResourceFileCache::newFromContext( $context );
696
			if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
697
				return; // output handled
698
			}
699
		}
700
701
		// Generate a response
702
		$response = $this->makeModuleResponse( $context, $modules, $missing );
703
704
		// Capture any PHP warnings from the output buffer and append them to the
705
		// error list if we're in debug mode.
706
		if ( $context->getDebug() ) {
707
			$warnings = ob_get_contents();
708
			if ( strlen( $warnings ) ) {
709
				$this->errors[] = $warnings;
710
			}
711
		}
712
713
		// Save response to file cache unless there are errors
714
		if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
715
			// Cache single modules and images...and other requests if there are enough hits
716
			if ( ResourceFileCache::useFileCache( $context ) ) {
717
				if ( $fileCache->isCacheWorthy() ) {
718
					$fileCache->saveText( $response );
719
				} else {
720
					$fileCache->incrMissesRecent( $context->getRequest() );
721
				}
722
			}
723
		}
724
725
		$this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
726
727
		// Remove the output buffer and output the response
728
		ob_end_clean();
729
730
		if ( $context->getImageObj() && $this->errors ) {
731
			// We can't show both the error messages and the response when it's an image.
732
			$response = implode( "\n\n", $this->errors );
733
		} elseif ( $this->errors ) {
734
			$errorText = implode( "\n\n", $this->errors );
735
			$errorResponse = self::makeComment( $errorText );
736
			if ( $context->shouldIncludeScripts() ) {
737
				$errorResponse .= 'if (window.console && console.error) {'
738
					. Xml::encodeJsCall( 'console.error', [ $errorText ] )
739
					. "}\n";
740
			}
741
742
			// Prepend error info to the response
743
			$response = $errorResponse . $response;
744
		}
745
746
		$this->errors = [];
747
		echo $response;
748
749
	}
750
751
	/**
752
	 * Send main response headers to the client.
753
	 *
754
	 * Deals with Content-Type, CORS (for stylesheets), and caching.
755
	 *
756
	 * @param ResourceLoaderContext $context
757
	 * @param string $etag ETag header value
758
	 * @param bool $errors Whether there are errors in the response
759
	 * @return void
760
	 */
761
	protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
762
		$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
763
		// If a version wasn't specified we need a shorter expiry time for updates
764
		// to propagate to clients quickly
765
		// If there were errors, we also need a shorter expiry time so we can recover quickly
766
		if ( is_null( $context->getVersion() ) || $errors ) {
767
			$maxage = $rlMaxage['unversioned']['client'];
768
			$smaxage = $rlMaxage['unversioned']['server'];
769
		// If a version was specified we can use a longer expiry time since changing
770
		// version numbers causes cache misses
771
		} else {
772
			$maxage = $rlMaxage['versioned']['client'];
773
			$smaxage = $rlMaxage['versioned']['server'];
774
		}
775
		if ( $context->getImageObj() ) {
776
			// Output different headers if we're outputting textual errors.
777
			if ( $errors ) {
778
				header( 'Content-Type: text/plain; charset=utf-8' );
779
			} else {
780
				$context->getImageObj()->sendResponseHeaders( $context );
781
			}
782
		} elseif ( $context->getOnly() === 'styles' ) {
783
			header( 'Content-Type: text/css; charset=utf-8' );
784
			header( 'Access-Control-Allow-Origin: *' );
785
		} else {
786
			header( 'Content-Type: text/javascript; charset=utf-8' );
787
		}
788
		// See RFC 2616 § 14.19 ETag
789
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
790
		header( 'ETag: ' . $etag );
791
		if ( $context->getDebug() ) {
792
			// Do not cache debug responses
793
			header( 'Cache-Control: private, no-cache, must-revalidate' );
794
			header( 'Pragma: no-cache' );
795
		} else {
796
			header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
797
			$exp = min( $maxage, $smaxage );
798
			header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
799
		}
800
	}
801
802
	/**
803
	 * Respond with HTTP 304 Not Modified if appropiate.
804
	 *
805
	 * If there's an If-None-Match header, respond with a 304 appropriately
806
	 * and clear out the output buffer. If the client cache is too old then do nothing.
807
	 *
808
	 * @param ResourceLoaderContext $context
809
	 * @param string $etag ETag header value
810
	 * @return bool True if HTTP 304 was sent and output handled
811
	 */
812
	protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
813
		// See RFC 2616 § 14.26 If-None-Match
814
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
815
		$clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
816
		// Never send 304s in debug mode
817
		if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
818
			// There's another bug in ob_gzhandler (see also the comment at
819
			// the top of this function) that causes it to gzip even empty
820
			// responses, meaning it's impossible to produce a truly empty
821
			// response (because the gzip header is always there). This is
822
			// a problem because 304 responses have to be completely empty
823
			// per the HTTP spec, and Firefox behaves buggily when they're not.
824
			// See also http://bugs.php.net/bug.php?id=51579
825
			// To work around this, we tear down all output buffering before
826
			// sending the 304.
827
			wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
828
829
			HttpStatus::header( 304 );
830
831
			$this->sendResponseHeaders( $context, $etag, false );
832
			return true;
833
		}
834
		return false;
835
	}
836
837
	/**
838
	 * Send out code for a response from file cache if possible.
839
	 *
840
	 * @param ResourceFileCache $fileCache Cache object for this request URL
841
	 * @param ResourceLoaderContext $context Context in which to generate a response
842
	 * @param string $etag ETag header value
843
	 * @return bool If this found a cache file and handled the response
844
	 */
845
	protected function tryRespondFromFileCache(
846
		ResourceFileCache $fileCache,
847
		ResourceLoaderContext $context,
848
		$etag
849
	) {
850
		$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
851
		// Buffer output to catch warnings.
852
		ob_start();
853
		// Get the maximum age the cache can be
854
		$maxage = is_null( $context->getVersion() )
855
			? $rlMaxage['unversioned']['server']
856
			: $rlMaxage['versioned']['server'];
857
		// Minimum timestamp the cache file must have
858
		$good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
0 ignored issues
show
Security Bug introduced by
It seems like wfTimestamp(TS_MW, time() - $maxage) targeting wfTimestamp() can also be of type false; however, FileCacheBase::isCacheGood() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
859
		if ( !$good ) {
860
			try { // RL always hits the DB on file cache miss...
861
				wfGetDB( DB_SLAVE );
862
			} catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
863
				$good = $fileCache->isCacheGood(); // cache existence check
864
			}
865
		}
866
		if ( $good ) {
867
			$ts = $fileCache->cacheTimestamp();
868
			// Send content type and cache headers
869
			$this->sendResponseHeaders( $context, $etag, false );
870
			$response = $fileCache->fetchText();
871
			// Capture any PHP warnings from the output buffer and append them to the
872
			// response in a comment if we're in debug mode.
873
			if ( $context->getDebug() ) {
874
				$warnings = ob_get_contents();
875
				if ( strlen( $warnings ) ) {
876
					$response = self::makeComment( $warnings ) . $response;
877
				}
878
			}
879
			// Remove the output buffer and output the response
880
			ob_end_clean();
881
			echo $response . "\n/* Cached {$ts} */";
882
			return true; // cache hit
883
		}
884
		// Clear buffer
885
		ob_end_clean();
886
887
		return false; // cache miss
888
	}
889
890
	/**
891
	 * Generate a CSS or JS comment block.
892
	 *
893
	 * Only use this for public data, not error message details.
894
	 *
895
	 * @param string $text
896
	 * @return string
897
	 */
898
	public static function makeComment( $text ) {
899
		$encText = str_replace( '*/', '* /', $text );
900
		return "/*\n$encText\n*/\n";
901
	}
902
903
	/**
904
	 * Handle exception display.
905
	 *
906
	 * @param Exception $e Exception to be shown to the user
907
	 * @return string Sanitized text in a CSS/JS comment that can be returned to the user
908
	 */
909
	public static function formatException( $e ) {
910
		return self::makeComment( self::formatExceptionNoComment( $e ) );
911
	}
912
913
	/**
914
	 * Handle exception display.
915
	 *
916
	 * @since 1.25
917
	 * @param Exception $e Exception to be shown to the user
918
	 * @return string Sanitized text that can be returned to the user
919
	 */
920
	protected static function formatExceptionNoComment( $e ) {
921
		global $wgShowExceptionDetails;
922
923
		if ( !$wgShowExceptionDetails ) {
924
			return MWExceptionHandler::getPublicLogMessage( $e );
925
		}
926
927
		return MWExceptionHandler::getLogMessage( $e );
928
	}
929
930
	/**
931
	 * Generate code for a response.
932
	 *
933
	 * @param ResourceLoaderContext $context Context in which to generate a response
934
	 * @param ResourceLoaderModule[] $modules List of module objects keyed by module name
935
	 * @param string[] $missing List of requested module names that are unregistered (optional)
936
	 * @return string Response data
937
	 */
938
	public function makeModuleResponse( ResourceLoaderContext $context,
939
		array $modules, array $missing = []
940
	) {
941
		$out = '';
942
		$states = [];
943
944
		if ( !count( $modules ) && !count( $missing ) ) {
945
			return <<<MESSAGE
946
/* This file is the Web entry point for MediaWiki's ResourceLoader:
947
   <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
948
   no modules were requested. Max made me put this here. */
949
MESSAGE;
950
		}
951
952
		$image = $context->getImageObj();
953
		if ( $image ) {
954
			$data = $image->getImageData( $context );
955
			if ( $data === false ) {
956
				$data = '';
957
				$this->errors[] = 'Image generation failed';
958
			}
959
			return $data;
960
		}
961
962
		foreach ( $missing as $name ) {
963
			$states[$name] = 'missing';
964
		}
965
966
		// Generate output
967
		$isRaw = false;
968
969
		$filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
970
971
		foreach ( $modules as $name => $module ) {
972
			try {
973
				$content = $module->getModuleContent( $context );
974
				$strContent = '';
975
976
				// Append output
977
				switch ( $context->getOnly() ) {
978
					case 'scripts':
979
						$scripts = $content['scripts'];
980
						if ( is_string( $scripts ) ) {
981
							// Load scripts raw...
982
							$strContent = $scripts;
983
						} elseif ( is_array( $scripts ) ) {
984
							// ...except when $scripts is an array of URLs
985
							$strContent = self::makeLoaderImplementScript( $name, $scripts, [], [], [] );
986
						}
987
						break;
988
					case 'styles':
989
						$styles = $content['styles'];
990
						// We no longer seperate into media, they are all combined now with
991
						// custom media type groups into @media .. {} sections as part of the css string.
992
						// Module returns either an empty array or a numerical array with css strings.
993
						$strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
994
						break;
995
					default:
996
						$strContent = self::makeLoaderImplementScript(
997
							$name,
998
							isset( $content['scripts'] ) ? $content['scripts'] : '',
999
							isset( $content['styles'] ) ? $content['styles'] : [],
1000
							isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1001
							isset( $content['templates'] ) ? $content['templates'] : []
1002
						);
1003
						break;
1004
				}
1005
1006
				if ( !$context->getDebug() ) {
1007
					$strContent = self::filter( $filter, $strContent );
0 ignored issues
show
Security Bug introduced by
It seems like $strContent can also be of type false; however, ResourceLoader::filter() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
1008
				}
1009
1010
				$out .= $strContent;
1011
1012
			} catch ( Exception $e ) {
1013
				MWExceptionHandler::logException( $e );
1014
				$this->logger->warning( 'Generating module package failed: {exception}', [
1015
					'exception' => $e
1016
				] );
1017
				$this->errors[] = self::formatExceptionNoComment( $e );
1018
1019
				// Respond to client with error-state instead of module implementation
1020
				$states[$name] = 'error';
1021
				unset( $modules[$name] );
1022
			}
1023
			$isRaw |= $module->isRaw();
1024
		}
1025
1026
		// Update module states
1027
		if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1028
			if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1029
				// Set the state of modules loaded as only scripts to ready as
1030
				// they don't have an mw.loader.implement wrapper that sets the state
1031
				foreach ( $modules as $name => $module ) {
1032
					$states[$name] = 'ready';
1033
				}
1034
			}
1035
1036
			// Set the state of modules we didn't respond to with mw.loader.implement
1037
			if ( count( $states ) ) {
1038
				$stateScript = self::makeLoaderStateScript( $states );
1039
				if ( !$context->getDebug() ) {
1040
					$stateScript = self::filter( 'minify-js', $stateScript );
0 ignored issues
show
Security Bug introduced by
It seems like $stateScript can also be of type false; however, ResourceLoader::filter() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
1041
				}
1042
				$out .= $stateScript;
1043
			}
1044
		} else {
1045
			if ( count( $states ) ) {
1046
				$this->errors[] = 'Problematic modules: ' .
1047
					FormatJson::encode( $states, ResourceLoader::inDebugMode() );
1048
			}
1049
		}
1050
1051
		return $out;
1052
	}
1053
1054
	/**
1055
	 * Get names of modules that use a certain message.
1056
	 *
1057
	 * @param string $messageKey
1058
	 * @return array List of module names
1059
	 */
1060
	public function getModulesByMessage( $messageKey ) {
1061
		$moduleNames = [];
1062
		foreach ( $this->getModuleNames() as $moduleName ) {
1063
			$module = $this->getModule( $moduleName );
1064
			if ( in_array( $messageKey, $module->getMessages() ) ) {
1065
				$moduleNames[] = $moduleName;
1066
			}
1067
		}
1068
		return $moduleNames;
1069
	}
1070
1071
	/* Static Methods */
1072
1073
	/**
1074
	 * Return JS code that calls mw.loader.implement with given module properties.
1075
	 *
1076
	 * @param string $name Module name
1077
	 * @param mixed $scripts List of URLs to JavaScript files or String of JavaScript code
1078
	 * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
1079
	 *   to CSS files keyed by media type
1080
	 * @param mixed $messages List of messages associated with this module. May either be an
1081
	 *   associative array mapping message key to value, or a JSON-encoded message blob containing
1082
	 *   the same data, wrapped in an XmlJsCode object.
1083
	 * @param array $templates Keys are name of templates and values are the source of
1084
	 *   the template.
1085
	 * @throws MWException
1086
	 * @return string
1087
	 */
1088
	public static function makeLoaderImplementScript(
1089
		$name, $scripts, $styles, $messages, $templates
1090
	) {
1091
		if ( is_string( $scripts ) ) {
1092
			// Site and user module are a legacy scripts that run in the global scope (no closure).
1093
			// Transportation as string instructs mw.loader.implement to use globalEval.
1094
			if ( $name === 'site' || $name === 'user' ) {
1095
				// Minify manually because the general makeModuleResponse() minification won't be
1096
				// effective here due to the script being a string instead of a function. (T107377)
1097
				if ( !ResourceLoader::inDebugMode() ) {
1098
					$scripts = self::filter( 'minify-js', $scripts );
1099
				}
1100
			} else {
1101
				$scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts}\n}" );
1102
			}
1103
		} elseif ( !is_array( $scripts ) ) {
1104
			throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1105
		}
1106
		// mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1107
		// arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1108
		// of "{}". Force them to objects.
1109
		$module = [
1110
			$name,
1111
			$scripts,
1112
			(object)$styles,
1113
			(object)$messages,
1114
			(object)$templates,
1115
		];
1116
		self::trimArray( $module );
1117
1118
		return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1119
	}
1120
1121
	/**
1122
	 * Returns JS code which, when called, will register a given list of messages.
1123
	 *
1124
	 * @param mixed $messages Either an associative array mapping message key to value, or a
1125
	 *   JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
1126
	 * @return string
1127
	 */
1128
	public static function makeMessageSetScript( $messages ) {
1129
		return Xml::encodeJsCall(
1130
			'mw.messages.set',
1131
			[ (object)$messages ],
1132
			ResourceLoader::inDebugMode()
1133
		);
1134
	}
1135
1136
	/**
1137
	 * Combines an associative array mapping media type to CSS into a
1138
	 * single stylesheet with "@media" blocks.
1139
	 *
1140
	 * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings
1141
	 * @return array
1142
	 */
1143
	public static function makeCombinedStyles( array $stylePairs ) {
1144
		$out = [];
1145
		foreach ( $stylePairs as $media => $styles ) {
1146
			// ResourceLoaderFileModule::getStyle can return the styles
1147
			// as a string or an array of strings. This is to allow separation in
1148
			// the front-end.
1149
			$styles = (array)$styles;
1150
			foreach ( $styles as $style ) {
1151
				$style = trim( $style );
1152
				// Don't output an empty "@media print { }" block (bug 40498)
1153
				if ( $style !== '' ) {
1154
					// Transform the media type based on request params and config
1155
					// The way that this relies on $wgRequest to propagate request params is slightly evil
1156
					$media = OutputPage::transformCssMedia( $media );
1157
1158
					if ( $media === '' || $media == 'all' ) {
1159
						$out[] = $style;
1160
					} elseif ( is_string( $media ) ) {
1161
						$out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1162
					}
1163
					// else: skip
1164
				}
1165
			}
1166
		}
1167
		return $out;
1168
	}
1169
1170
	/**
1171
	 * Returns a JS call to mw.loader.state, which sets the state of a
1172
	 * module or modules to a given value. Has two calling conventions:
1173
	 *
1174
	 *    - ResourceLoader::makeLoaderStateScript( $name, $state ):
1175
	 *         Set the state of a single module called $name to $state
1176
	 *
1177
	 *    - ResourceLoader::makeLoaderStateScript( array( $name => $state, ... ) ):
1178
	 *         Set the state of modules with the given names to the given states
1179
	 *
1180
	 * @param string $name
1181
	 * @param string $state
1182
	 * @return string
1183
	 */
1184 View Code Duplication
	public static function makeLoaderStateScript( $name, $state = null ) {
1185
		if ( is_array( $name ) ) {
1186
			return Xml::encodeJsCall(
1187
				'mw.loader.state',
1188
				[ $name ],
1189
				ResourceLoader::inDebugMode()
1190
			);
1191
		} else {
1192
			return Xml::encodeJsCall(
1193
				'mw.loader.state',
1194
				[ $name, $state ],
1195
				ResourceLoader::inDebugMode()
1196
			);
1197
		}
1198
	}
1199
1200
	/**
1201
	 * Returns JS code which calls the script given by $script. The script will
1202
	 * be called with local variables name, version, dependencies and group,
1203
	 * which will have values corresponding to $name, $version, $dependencies
1204
	 * and $group as supplied.
1205
	 *
1206
	 * @param string $name Module name
1207
	 * @param string $version Module version hash
1208
	 * @param array $dependencies List of module names on which this module depends
1209
	 * @param string $group Group which the module is in.
1210
	 * @param string $source Source of the module, or 'local' if not foreign.
1211
	 * @param string $script JavaScript code
1212
	 * @return string
1213
	 */
1214
	public static function makeCustomLoaderScript( $name, $version, $dependencies,
1215
		$group, $source, $script
1216
	) {
1217
		$script = str_replace( "\n", "\n\t", trim( $script ) );
1218
		return Xml::encodeJsCall(
1219
			"( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1220
			[ $name, $version, $dependencies, $group, $source ],
1221
			ResourceLoader::inDebugMode()
1222
		);
1223
	}
1224
1225
	private static function isEmptyObject( stdClass $obj ) {
1226
		foreach ( $obj as $key => $value ) {
0 ignored issues
show
Bug introduced by
The expression $obj of type object<stdClass> is not traversable.
Loading history...
1227
			return false;
1228
		}
1229
		return true;
1230
	}
1231
1232
	/**
1233
	 * Remove empty values from the end of an array.
1234
	 *
1235
	 * Values considered empty:
1236
	 *
1237
	 * - null
1238
	 * - array()
1239
	 * - new XmlJsCode( '{}' )
1240
	 * - new stdClass() // (object) array()
1241
	 *
1242
	 * @param Array $array
1243
	 */
1244
	private static function trimArray( array &$array ) {
1245
		$i = count( $array );
1246
		while ( $i-- ) {
1247
			if ( $array[$i] === null
1248
				|| $array[$i] === []
1249
				|| ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1250
				|| ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1251
			) {
1252
				unset( $array[$i] );
1253
			} else {
1254
				break;
1255
			}
1256
		}
1257
	}
1258
1259
	/**
1260
	 * Returns JS code which calls mw.loader.register with the given
1261
	 * parameters. Has three calling conventions:
1262
	 *
1263
	 *   - ResourceLoader::makeLoaderRegisterScript( $name, $version,
1264
	 *        $dependencies, $group, $source, $skip
1265
	 *     ):
1266
	 *        Register a single module.
1267
	 *
1268
	 *   - ResourceLoader::makeLoaderRegisterScript( array( $name1, $name2 ) ):
1269
	 *        Register modules with the given names.
1270
	 *
1271
	 *   - ResourceLoader::makeLoaderRegisterScript( array(
1272
	 *        array( $name1, $version1, $dependencies1, $group1, $source1, $skip1 ),
1273
	 *        array( $name2, $version2, $dependencies1, $group2, $source2, $skip2 ),
1274
	 *        ...
1275
	 *     ) ):
1276
	 *        Registers modules with the given names and parameters.
1277
	 *
1278
	 * @param string $name Module name
1279
	 * @param string $version Module version hash
1280
	 * @param array $dependencies List of module names on which this module depends
1281
	 * @param string $group Group which the module is in
1282
	 * @param string $source Source of the module, or 'local' if not foreign
1283
	 * @param string $skip Script body of the skip function
1284
	 * @return string
1285
	 */
1286
	public static function makeLoaderRegisterScript( $name, $version = null,
1287
		$dependencies = null, $group = null, $source = null, $skip = null
1288
	) {
1289
		if ( is_array( $name ) ) {
1290
			// Build module name index
1291
			$index = [];
1292
			foreach ( $name as $i => &$module ) {
1293
				$index[$module[0]] = $i;
1294
			}
1295
1296
			// Transform dependency names into indexes when possible, they will be resolved by
1297
			// mw.loader.register on the other end
1298
			foreach ( $name as &$module ) {
1299
				if ( isset( $module[2] ) ) {
1300
					foreach ( $module[2] as &$dependency ) {
1301
						if ( isset( $index[$dependency] ) ) {
1302
							$dependency = $index[$dependency];
1303
						}
1304
					}
1305
				}
1306
			}
1307
1308
			array_walk( $name, [ 'self', 'trimArray' ] );
1309
1310
			return Xml::encodeJsCall(
1311
				'mw.loader.register',
1312
				[ $name ],
1313
				ResourceLoader::inDebugMode()
1314
			);
1315
		} else {
1316
			$registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1317
			self::trimArray( $registration );
1318
			return Xml::encodeJsCall(
1319
				'mw.loader.register',
1320
				$registration,
1321
				ResourceLoader::inDebugMode()
1322
			);
1323
		}
1324
	}
1325
1326
	/**
1327
	 * Returns JS code which calls mw.loader.addSource() with the given
1328
	 * parameters. Has two calling conventions:
1329
	 *
1330
	 *   - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
1331
	 *       Register a single source
1332
	 *
1333
	 *   - ResourceLoader::makeLoaderSourcesScript( array( $id1 => $loadUrl, $id2 => $loadUrl, ... ) );
1334
	 *       Register sources with the given IDs and properties.
1335
	 *
1336
	 * @param string $id Source ID
1337
	 * @param array $properties Source properties (see addSource())
1338
	 * @return string
1339
	 */
1340 View Code Duplication
	public static function makeLoaderSourcesScript( $id, $properties = null ) {
1341
		if ( is_array( $id ) ) {
1342
			return Xml::encodeJsCall(
1343
				'mw.loader.addSource',
1344
				[ $id ],
1345
				ResourceLoader::inDebugMode()
1346
			);
1347
		} else {
1348
			return Xml::encodeJsCall(
1349
				'mw.loader.addSource',
1350
				[ $id, $properties ],
1351
				ResourceLoader::inDebugMode()
1352
			);
1353
		}
1354
	}
1355
1356
	/**
1357
	 * Returns JS code which runs given JS code if the client-side framework is
1358
	 * present.
1359
	 *
1360
	 * @deprecated since 1.25; use makeInlineScript instead
1361
	 * @param string $script JavaScript code
1362
	 * @return string
1363
	 */
1364
	public static function makeLoaderConditionalScript( $script ) {
1365
		return '(window.RLQ=window.RLQ||[]).push(function(){' .
1366
			trim( $script ) . '});';
1367
	}
1368
1369
	/**
1370
	 * Construct an inline script tag with given JS code.
1371
	 *
1372
	 * The code will be wrapped in a closure, and it will be executed by ResourceLoader
1373
	 * only if the client has adequate support for MediaWiki JavaScript code.
1374
	 *
1375
	 * @param string $script JavaScript code
1376
	 * @return WrappedString HTML
1377
	 */
1378
	public static function makeInlineScript( $script ) {
1379
		$js = self::makeLoaderConditionalScript( $script );
0 ignored issues
show
Deprecated Code introduced by
The method ResourceLoader::makeLoaderConditionalScript() has been deprecated with message: since 1.25; use makeInlineScript instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1380
		return new WrappedString(
1381
			Html::inlineScript( $js ),
1382
			'<script>(window.RLQ=window.RLQ||[]).push(function(){',
1383
			'});</script>'
1384
		);
1385
	}
1386
1387
	/**
1388
	 * Returns JS code which will set the MediaWiki configuration array to
1389
	 * the given value.
1390
	 *
1391
	 * @param array $configuration List of configuration values keyed by variable name
1392
	 * @return string
1393
	 */
1394
	public static function makeConfigSetScript( array $configuration ) {
1395
		return Xml::encodeJsCall(
1396
			'mw.config.set',
1397
			[ $configuration ],
1398
			ResourceLoader::inDebugMode()
1399
		);
1400
	}
1401
1402
	/**
1403
	 * Convert an array of module names to a packed query string.
1404
	 *
1405
	 * For example, array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' )
1406
	 * becomes 'foo.bar,baz|bar.baz,quux'
1407
	 * @param array $modules List of module names (strings)
1408
	 * @return string Packed query string
1409
	 */
1410
	public static function makePackedModulesString( $modules ) {
1411
		$groups = []; // array( prefix => array( suffixes ) )
1412
		foreach ( $modules as $module ) {
1413
			$pos = strrpos( $module, '.' );
1414
			$prefix = $pos === false ? '' : substr( $module, 0, $pos );
1415
			$suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1416
			$groups[$prefix][] = $suffix;
1417
		}
1418
1419
		$arr = [];
1420
		foreach ( $groups as $prefix => $suffixes ) {
1421
			$p = $prefix === '' ? '' : $prefix . '.';
1422
			$arr[] = $p . implode( ',', $suffixes );
1423
		}
1424
		$str = implode( '|', $arr );
1425
		return $str;
1426
	}
1427
1428
	/**
1429
	 * Determine whether debug mode was requested
1430
	 * Order of priority is 1) request param, 2) cookie, 3) $wg setting
1431
	 * @return bool
1432
	 */
1433
	public static function inDebugMode() {
1434
		if ( self::$debugMode === null ) {
1435
			global $wgRequest, $wgResourceLoaderDebug;
1436
			self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1437
				$wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1438
			);
1439
		}
1440
		return self::$debugMode;
1441
	}
1442
1443
	/**
1444
	 * Reset static members used for caching.
1445
	 *
1446
	 * Global state and $wgRequest are evil, but we're using it right
1447
	 * now and sometimes we need to be able to force ResourceLoader to
1448
	 * re-evaluate the context because it has changed (e.g. in the test suite).
1449
	 */
1450
	public static function clearCache() {
1451
		self::$debugMode = null;
1452
	}
1453
1454
	/**
1455
	 * Build a load.php URL
1456
	 *
1457
	 * @since 1.24
1458
	 * @param string $source Name of the ResourceLoader source
1459
	 * @param ResourceLoaderContext $context
1460
	 * @param array $extraQuery
1461
	 * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1462
	 */
1463
	public function createLoaderURL( $source, ResourceLoaderContext $context,
1464
		$extraQuery = []
1465
	) {
1466
		$query = self::createLoaderQuery( $context, $extraQuery );
1467
		$script = $this->getLoadScript( $source );
1468
1469
		return wfAppendQuery( $script, $query );
1470
	}
1471
1472
	/**
1473
	 * Build a load.php URL
1474
	 * @deprecated since 1.24 Use createLoaderURL() instead
1475
	 * @param array $modules Array of module names (strings)
1476
	 * @param string $lang Language code
1477
	 * @param string $skin Skin name
1478
	 * @param string|null $user User name. If null, the &user= parameter is omitted
1479
	 * @param string|null $version Versioning timestamp
1480
	 * @param bool $debug Whether the request should be in debug mode
1481
	 * @param string|null $only &only= parameter
1482
	 * @param bool $printable Printable mode
1483
	 * @param bool $handheld Handheld mode
1484
	 * @param array $extraQuery Extra query parameters to add
1485
	 * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1486
	 */
1487
	public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
1488
		$version = null, $debug = false, $only = null, $printable = false,
1489
		$handheld = false, $extraQuery = []
1490
	) {
1491
		global $wgLoadScript;
1492
1493
		$query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
1494
			$only, $printable, $handheld, $extraQuery
1495
		);
1496
1497
		return wfAppendQuery( $wgLoadScript, $query );
1498
	}
1499
1500
	/**
1501
	 * Helper for createLoaderURL()
1502
	 *
1503
	 * @since 1.24
1504
	 * @see makeLoaderQuery
1505
	 * @param ResourceLoaderContext $context
1506
	 * @param array $extraQuery
1507
	 * @return array
1508
	 */
1509
	public static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1510
		return self::makeLoaderQuery(
1511
			$context->getModules(),
1512
			$context->getLanguage(),
1513
			$context->getSkin(),
1514
			$context->getUser(),
1515
			$context->getVersion(),
1516
			$context->getDebug(),
1517
			$context->getOnly(),
1518
			$context->getRequest()->getBool( 'printable' ),
1519
			$context->getRequest()->getBool( 'handheld' ),
1520
			$extraQuery
1521
		);
1522
	}
1523
1524
	/**
1525
	 * Build a query array (array representation of query string) for load.php. Helper
1526
	 * function for makeLoaderURL().
1527
	 *
1528
	 * @param array $modules
1529
	 * @param string $lang
1530
	 * @param string $skin
1531
	 * @param string $user
1532
	 * @param string $version
1533
	 * @param bool $debug
1534
	 * @param string $only
1535
	 * @param bool $printable
1536
	 * @param bool $handheld
1537
	 * @param array $extraQuery
1538
	 *
1539
	 * @return array
1540
	 */
1541
	public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1542
		$version = null, $debug = false, $only = null, $printable = false,
1543
		$handheld = false, $extraQuery = []
1544
	) {
1545
		$query = [
1546
			'modules' => self::makePackedModulesString( $modules ),
1547
			'lang' => $lang,
1548
			'skin' => $skin,
1549
			'debug' => $debug ? 'true' : 'false',
1550
		];
1551
		if ( $user !== null ) {
1552
			$query['user'] = $user;
1553
		}
1554
		if ( $version !== null ) {
1555
			$query['version'] = $version;
1556
		}
1557
		if ( $only !== null ) {
1558
			$query['only'] = $only;
1559
		}
1560
		if ( $printable ) {
1561
			$query['printable'] = 1;
1562
		}
1563
		if ( $handheld ) {
1564
			$query['handheld'] = 1;
1565
		}
1566
		$query += $extraQuery;
1567
1568
		// Make queries uniform in order
1569
		ksort( $query );
1570
		return $query;
1571
	}
1572
1573
	/**
1574
	 * Check a module name for validity.
1575
	 *
1576
	 * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1577
	 * at most 255 bytes.
1578
	 *
1579
	 * @param string $moduleName Module name to check
1580
	 * @return bool Whether $moduleName is a valid module name
1581
	 */
1582
	public static function isValidModuleName( $moduleName ) {
1583
		return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1584
	}
1585
1586
	/**
1587
	 * Returns LESS compiler set up for use with MediaWiki
1588
	 *
1589
	 * @since 1.27
1590
	 * @param array $extraVars Associative array of extra (i.e., other than the
1591
	 *   globally-configured ones) that should be used for compilation.
1592
	 * @throws MWException
1593
	 * @return Less_Parser
1594
	 */
1595
	public function getLessCompiler( $extraVars = [] ) {
1596
		// When called from the installer, it is possible that a required PHP extension
1597
		// is missing (at least for now; see bug 47564). If this is the case, throw an
1598
		// exception (caught by the installer) to prevent a fatal error later on.
1599
		if ( !class_exists( 'Less_Parser' ) ) {
1600
			throw new MWException( 'MediaWiki requires the less.php parser' );
1601
		}
1602
1603
		$parser = new Less_Parser;
1604
		$parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1605
		$parser->SetImportDirs(
1606
			array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1607
		);
1608
		$parser->SetOption( 'relativeUrls', false );
1609
		$parser->SetCacheDir( $this->config->get( 'CacheDirectory' ) ?: wfTempDir() );
0 ignored issues
show
Deprecated Code introduced by
The method Less_Parser::SetCacheDir() has been deprecated with message: 1.5.1.2

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
1610
1611
		return $parser;
1612
	}
1613
1614
	/**
1615
	 * Get global LESS variables.
1616
	 *
1617
	 * @since 1.27
1618
	 * @return array Map of variable names to string CSS values.
1619
	 */
1620
	public function getLessVars() {
1621
		if ( !$this->lessVars ) {
1622
			$lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1623
			Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1624
			$this->lessVars = $lessVars;
0 ignored issues
show
Documentation Bug introduced by
It seems like $lessVars of type * is incompatible with the declared type array of property $lessVars.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1625
		}
1626
		return $this->lessVars;
1627
	}
1628
}
1629