Completed
Branch master (5998bb)
by
unknown
29:17
created

ResourceLoader::makeLoaderURL()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 10
dl 0
loc 12
rs 9.4285
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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