Completed
Branch master (9259dd)
by
unknown
27:26
created

ResourceLoader::getCombinedVersion()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 2
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
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
		// Use base64 to output more entropy in a more compact string (default hex is only base16).
605
		// The first 8 chars of a base64 encoded digest represent the same binary as
606
		// the first 12 chars of a hex encoded digest.
607
		return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
608
	}
609
610
	/**
611
	 * Helper method to get and combine versions of multiple modules.
612
	 *
613
	 * @since 1.26
614
	 * @param ResourceLoaderContext $context
615
	 * @param array $modules List of ResourceLoaderModule objects
616
	 * @return string Hash
617
	 */
618
	public function getCombinedVersion( ResourceLoaderContext $context, array $modules ) {
619
		if ( !$modules ) {
620
			return '';
621
		}
622
		$hashes = array_map( function ( $module ) use ( $context ) {
623
			return $this->getModule( $module )->getVersionHash( $context );
624
		}, $modules );
625
		return self::makeHash( implode( $hashes ) );
626
	}
627
628
	/**
629
	 * Output a response to a load request, including the content-type header.
630
	 *
631
	 * @param ResourceLoaderContext $context Context in which a response should be formed
632
	 */
633
	public function respond( ResourceLoaderContext $context ) {
634
		// Buffer output to catch warnings. Normally we'd use ob_clean() on the
635
		// top-level output buffer to clear warnings, but that breaks when ob_gzhandler
636
		// is used: ob_clean() will clear the GZIP header in that case and it won't come
637
		// back for subsequent output, resulting in invalid GZIP. So we have to wrap
638
		// the whole thing in our own output buffer to be sure the active buffer
639
		// doesn't use ob_gzhandler.
640
		// See http://bugs.php.net/bug.php?id=36514
641
		ob_start();
642
643
		// Find out which modules are missing and instantiate the others
644
		$modules = [];
645
		$missing = [];
646
		foreach ( $context->getModules() as $name ) {
647
			$module = $this->getModule( $name );
648
			if ( $module ) {
649
				// Do not allow private modules to be loaded from the web.
650
				// This is a security issue, see bug 34907.
651
				if ( $module->getGroup() === 'private' ) {
652
					$this->logger->debug( "Request for private module '$name' denied" );
653
					$this->errors[] = "Cannot show private module \"$name\"";
654
					continue;
655
				}
656
				$modules[$name] = $module;
657
			} else {
658
				$missing[] = $name;
659
			}
660
		}
661
662
		try {
663
			// Preload for getCombinedVersion() and for batch makeModuleResponse()
664
			$this->preloadModuleInfo( array_keys( $modules ), $context );
665
		} catch ( Exception $e ) {
666
			MWExceptionHandler::logException( $e );
667
			$this->logger->warning( 'Preloading module info failed: {exception}', [
668
				'exception' => $e
669
			] );
670
			$this->errors[] = self::formatExceptionNoComment( $e );
671
		}
672
673
		// Combine versions to propagate cache invalidation
674
		$versionHash = '';
675
		try {
676
			$versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
677
		} catch ( Exception $e ) {
678
			MWExceptionHandler::logException( $e );
679
			$this->logger->warning( 'Calculating version hash failed: {exception}', [
680
				'exception' => $e
681
			] );
682
			$this->errors[] = self::formatExceptionNoComment( $e );
683
		}
684
685
		// See RFC 2616 § 3.11 Entity Tags
686
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
687
		$etag = 'W/"' . $versionHash . '"';
688
689
		// Try the client-side cache first
690
		if ( $this->tryRespondNotModified( $context, $etag ) ) {
691
			return; // output handled (buffers cleared)
692
		}
693
694
		// Use file cache if enabled and available...
695
		if ( $this->config->get( 'UseFileCache' ) ) {
696
			$fileCache = ResourceFileCache::newFromContext( $context );
697
			if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
698
				return; // output handled
699
			}
700
		}
701
702
		// Generate a response
703
		$response = $this->makeModuleResponse( $context, $modules, $missing );
704
705
		// Capture any PHP warnings from the output buffer and append them to the
706
		// error list if we're in debug mode.
707
		if ( $context->getDebug() ) {
708
			$warnings = ob_get_contents();
709
			if ( strlen( $warnings ) ) {
710
				$this->errors[] = $warnings;
711
			}
712
		}
713
714
		// Save response to file cache unless there are errors
715
		if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
716
			// Cache single modules and images...and other requests if there are enough hits
717
			if ( ResourceFileCache::useFileCache( $context ) ) {
718
				if ( $fileCache->isCacheWorthy() ) {
719
					$fileCache->saveText( $response );
720
				} else {
721
					$fileCache->incrMissesRecent( $context->getRequest() );
722
				}
723
			}
724
		}
725
726
		$this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
727
728
		// Remove the output buffer and output the response
729
		ob_end_clean();
730
731
		if ( $context->getImageObj() && $this->errors ) {
732
			// We can't show both the error messages and the response when it's an image.
733
			$response = implode( "\n\n", $this->errors );
734
		} elseif ( $this->errors ) {
735
			$errorText = implode( "\n\n", $this->errors );
736
			$errorResponse = self::makeComment( $errorText );
737
			if ( $context->shouldIncludeScripts() ) {
738
				$errorResponse .= 'if (window.console && console.error) {'
739
					. Xml::encodeJsCall( 'console.error', [ $errorText ] )
740
					. "}\n";
741
			}
742
743
			// Prepend error info to the response
744
			$response = $errorResponse . $response;
745
		}
746
747
		$this->errors = [];
748
		echo $response;
749
750
	}
751
752
	/**
753
	 * Send main response headers to the client.
754
	 *
755
	 * Deals with Content-Type, CORS (for stylesheets), and caching.
756
	 *
757
	 * @param ResourceLoaderContext $context
758
	 * @param string $etag ETag header value
759
	 * @param bool $errors Whether there are errors in the response
760
	 * @return void
761
	 */
762
	protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
763
		$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
764
		// If a version wasn't specified we need a shorter expiry time for updates
765
		// to propagate to clients quickly
766
		// If there were errors, we also need a shorter expiry time so we can recover quickly
767
		if ( is_null( $context->getVersion() ) || $errors ) {
768
			$maxage = $rlMaxage['unversioned']['client'];
769
			$smaxage = $rlMaxage['unversioned']['server'];
770
		// If a version was specified we can use a longer expiry time since changing
771
		// version numbers causes cache misses
772
		} else {
773
			$maxage = $rlMaxage['versioned']['client'];
774
			$smaxage = $rlMaxage['versioned']['server'];
775
		}
776
		if ( $context->getImageObj() ) {
777
			// Output different headers if we're outputting textual errors.
778
			if ( $errors ) {
779
				header( 'Content-Type: text/plain; charset=utf-8' );
780
			} else {
781
				$context->getImageObj()->sendResponseHeaders( $context );
782
			}
783
		} elseif ( $context->getOnly() === 'styles' ) {
784
			header( 'Content-Type: text/css; charset=utf-8' );
785
			header( 'Access-Control-Allow-Origin: *' );
786
		} else {
787
			header( 'Content-Type: text/javascript; charset=utf-8' );
788
		}
789
		// See RFC 2616 § 14.19 ETag
790
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
791
		header( 'ETag: ' . $etag );
792
		if ( $context->getDebug() ) {
793
			// Do not cache debug responses
794
			header( 'Cache-Control: private, no-cache, must-revalidate' );
795
			header( 'Pragma: no-cache' );
796
		} else {
797
			header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
798
			$exp = min( $maxage, $smaxage );
799
			header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
800
		}
801
	}
802
803
	/**
804
	 * Respond with HTTP 304 Not Modified if appropiate.
805
	 *
806
	 * If there's an If-None-Match header, respond with a 304 appropriately
807
	 * and clear out the output buffer. If the client cache is too old then do nothing.
808
	 *
809
	 * @param ResourceLoaderContext $context
810
	 * @param string $etag ETag header value
811
	 * @return bool True if HTTP 304 was sent and output handled
812
	 */
813
	protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
814
		// See RFC 2616 § 14.26 If-None-Match
815
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
816
		$clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
817
		// Never send 304s in debug mode
818
		if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
819
			// There's another bug in ob_gzhandler (see also the comment at
820
			// the top of this function) that causes it to gzip even empty
821
			// responses, meaning it's impossible to produce a truly empty
822
			// response (because the gzip header is always there). This is
823
			// a problem because 304 responses have to be completely empty
824
			// per the HTTP spec, and Firefox behaves buggily when they're not.
825
			// See also http://bugs.php.net/bug.php?id=51579
826
			// To work around this, we tear down all output buffering before
827
			// sending the 304.
828
			wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
829
830
			HttpStatus::header( 304 );
831
832
			$this->sendResponseHeaders( $context, $etag, false );
833
			return true;
834
		}
835
		return false;
836
	}
837
838
	/**
839
	 * Send out code for a response from file cache if possible.
840
	 *
841
	 * @param ResourceFileCache $fileCache Cache object for this request URL
842
	 * @param ResourceLoaderContext $context Context in which to generate a response
843
	 * @param string $etag ETag header value
844
	 * @return bool If this found a cache file and handled the response
845
	 */
846
	protected function tryRespondFromFileCache(
847
		ResourceFileCache $fileCache,
848
		ResourceLoaderContext $context,
849
		$etag
850
	) {
851
		$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
852
		// Buffer output to catch warnings.
853
		ob_start();
854
		// Get the maximum age the cache can be
855
		$maxage = is_null( $context->getVersion() )
856
			? $rlMaxage['unversioned']['server']
857
			: $rlMaxage['versioned']['server'];
858
		// Minimum timestamp the cache file must have
859
		$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...
860
		if ( !$good ) {
861
			try { // RL always hits the DB on file cache miss...
862
				wfGetDB( DB_SLAVE );
863
			} catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
864
				$good = $fileCache->isCacheGood(); // cache existence check
865
			}
866
		}
867
		if ( $good ) {
868
			$ts = $fileCache->cacheTimestamp();
869
			// Send content type and cache headers
870
			$this->sendResponseHeaders( $context, $etag, false );
871
			$response = $fileCache->fetchText();
872
			// Capture any PHP warnings from the output buffer and append them to the
873
			// response in a comment if we're in debug mode.
874
			if ( $context->getDebug() ) {
875
				$warnings = ob_get_contents();
876
				if ( strlen( $warnings ) ) {
877
					$response = self::makeComment( $warnings ) . $response;
878
				}
879
			}
880
			// Remove the output buffer and output the response
881
			ob_end_clean();
882
			echo $response . "\n/* Cached {$ts} */";
883
			return true; // cache hit
884
		}
885
		// Clear buffer
886
		ob_end_clean();
887
888
		return false; // cache miss
889
	}
890
891
	/**
892
	 * Generate a CSS or JS comment block.
893
	 *
894
	 * Only use this for public data, not error message details.
895
	 *
896
	 * @param string $text
897
	 * @return string
898
	 */
899
	public static function makeComment( $text ) {
900
		$encText = str_replace( '*/', '* /', $text );
901
		return "/*\n$encText\n*/\n";
902
	}
903
904
	/**
905
	 * Handle exception display.
906
	 *
907
	 * @param Exception $e Exception to be shown to the user
908
	 * @return string Sanitized text in a CSS/JS comment that can be returned to the user
909
	 */
910
	public static function formatException( $e ) {
911
		return self::makeComment( self::formatExceptionNoComment( $e ) );
912
	}
913
914
	/**
915
	 * Handle exception display.
916
	 *
917
	 * @since 1.25
918
	 * @param Exception $e Exception to be shown to the user
919
	 * @return string Sanitized text that can be returned to the user
920
	 */
921
	protected static function formatExceptionNoComment( $e ) {
922
		global $wgShowExceptionDetails;
923
924
		if ( !$wgShowExceptionDetails ) {
925
			return MWExceptionHandler::getPublicLogMessage( $e );
926
		}
927
928
		return MWExceptionHandler::getLogMessage( $e );
929
	}
930
931
	/**
932
	 * Generate code for a response.
933
	 *
934
	 * @param ResourceLoaderContext $context Context in which to generate a response
935
	 * @param ResourceLoaderModule[] $modules List of module objects keyed by module name
936
	 * @param string[] $missing List of requested module names that are unregistered (optional)
937
	 * @return string Response data
938
	 */
939
	public function makeModuleResponse( ResourceLoaderContext $context,
940
		array $modules, array $missing = []
941
	) {
942
		$out = '';
943
		$states = [];
944
945
		if ( !count( $modules ) && !count( $missing ) ) {
946
			return <<<MESSAGE
947
/* This file is the Web entry point for MediaWiki's ResourceLoader:
948
   <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
949
   no modules were requested. Max made me put this here. */
950
MESSAGE;
951
		}
952
953
		$image = $context->getImageObj();
954
		if ( $image ) {
955
			$data = $image->getImageData( $context );
956
			if ( $data === false ) {
957
				$data = '';
958
				$this->errors[] = 'Image generation failed';
959
			}
960
			return $data;
961
		}
962
963
		foreach ( $missing as $name ) {
964
			$states[$name] = 'missing';
965
		}
966
967
		// Generate output
968
		$isRaw = false;
969
970
		$filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
971
972
		foreach ( $modules as $name => $module ) {
973
			try {
974
				$content = $module->getModuleContent( $context );
975
				$strContent = '';
976
977
				// Append output
978
				switch ( $context->getOnly() ) {
979
					case 'scripts':
980
						$scripts = $content['scripts'];
981
						if ( is_string( $scripts ) ) {
982
							// Load scripts raw...
983
							$strContent = $scripts;
984
						} elseif ( is_array( $scripts ) ) {
985
							// ...except when $scripts is an array of URLs
986
							$strContent = self::makeLoaderImplementScript( $name, $scripts, [], [], [] );
987
						}
988
						break;
989
					case 'styles':
990
						$styles = $content['styles'];
991
						// We no longer seperate into media, they are all combined now with
992
						// custom media type groups into @media .. {} sections as part of the css string.
993
						// Module returns either an empty array or a numerical array with css strings.
994
						$strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
995
						break;
996
					default:
997
						$strContent = self::makeLoaderImplementScript(
998
							$name,
999
							isset( $content['scripts'] ) ? $content['scripts'] : '',
1000
							isset( $content['styles'] ) ? $content['styles'] : [],
1001
							isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
1002
							isset( $content['templates'] ) ? $content['templates'] : []
1003
						);
1004
						break;
1005
				}
1006
1007
				if ( !$context->getDebug() ) {
1008
					$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...
1009
				}
1010
1011
				$out .= $strContent;
1012
1013
			} catch ( Exception $e ) {
1014
				MWExceptionHandler::logException( $e );
1015
				$this->logger->warning( 'Generating module package failed: {exception}', [
1016
					'exception' => $e
1017
				] );
1018
				$this->errors[] = self::formatExceptionNoComment( $e );
1019
1020
				// Respond to client with error-state instead of module implementation
1021
				$states[$name] = 'error';
1022
				unset( $modules[$name] );
1023
			}
1024
			$isRaw |= $module->isRaw();
1025
		}
1026
1027
		// Update module states
1028
		if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
1029
			if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
1030
				// Set the state of modules loaded as only scripts to ready as
1031
				// they don't have an mw.loader.implement wrapper that sets the state
1032
				foreach ( $modules as $name => $module ) {
1033
					$states[$name] = 'ready';
1034
				}
1035
			}
1036
1037
			// Set the state of modules we didn't respond to with mw.loader.implement
1038
			if ( count( $states ) ) {
1039
				$stateScript = self::makeLoaderStateScript( $states );
1040
				if ( !$context->getDebug() ) {
1041
					$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...
1042
				}
1043
				$out .= $stateScript;
1044
			}
1045
		} else {
1046
			if ( count( $states ) ) {
1047
				$this->errors[] = 'Problematic modules: ' .
1048
					FormatJson::encode( $states, ResourceLoader::inDebugMode() );
1049
			}
1050
		}
1051
1052
		return $out;
1053
	}
1054
1055
	/**
1056
	 * Get names of modules that use a certain message.
1057
	 *
1058
	 * @param string $messageKey
1059
	 * @return array List of module names
1060
	 */
1061
	public function getModulesByMessage( $messageKey ) {
1062
		$moduleNames = [];
1063
		foreach ( $this->getModuleNames() as $moduleName ) {
1064
			$module = $this->getModule( $moduleName );
1065
			if ( in_array( $messageKey, $module->getMessages() ) ) {
1066
				$moduleNames[] = $moduleName;
1067
			}
1068
		}
1069
		return $moduleNames;
1070
	}
1071
1072
	/* Static Methods */
1073
1074
	/**
1075
	 * Return JS code that calls mw.loader.implement with given module properties.
1076
	 *
1077
	 * @param string $name Module name
1078
	 * @param mixed $scripts List of URLs to JavaScript files or String of JavaScript code
1079
	 * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
1080
	 *   to CSS files keyed by media type
1081
	 * @param mixed $messages List of messages associated with this module. May either be an
1082
	 *   associative array mapping message key to value, or a JSON-encoded message blob containing
1083
	 *   the same data, wrapped in an XmlJsCode object.
1084
	 * @param array $templates Keys are name of templates and values are the source of
1085
	 *   the template.
1086
	 * @throws MWException
1087
	 * @return string
1088
	 */
1089
	public static function makeLoaderImplementScript(
1090
		$name, $scripts, $styles, $messages, $templates
1091
	) {
1092
		if ( is_string( $scripts ) ) {
1093
			// Site and user module are a legacy scripts that run in the global scope (no closure).
1094
			// Transportation as string instructs mw.loader.implement to use globalEval.
1095
			if ( $name === 'site' || $name === 'user' ) {
1096
				// Minify manually because the general makeModuleResponse() minification won't be
1097
				// effective here due to the script being a string instead of a function. (T107377)
1098
				if ( !ResourceLoader::inDebugMode() ) {
1099
					$scripts = self::filter( 'minify-js', $scripts );
1100
				}
1101
			} else {
1102
				$scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts}\n}" );
1103
			}
1104
		} elseif ( !is_array( $scripts ) ) {
1105
			throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
1106
		}
1107
		// mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1108
		// arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1109
		// of "{}". Force them to objects.
1110
		$module = [
1111
			$name,
1112
			$scripts,
1113
			(object)$styles,
1114
			(object)$messages,
1115
			(object)$templates,
1116
		];
1117
		self::trimArray( $module );
1118
1119
		return Xml::encodeJsCall( 'mw.loader.implement', $module, ResourceLoader::inDebugMode() );
1120
	}
1121
1122
	/**
1123
	 * Returns JS code which, when called, will register a given list of messages.
1124
	 *
1125
	 * @param mixed $messages Either an associative array mapping message key to value, or a
1126
	 *   JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
1127
	 * @return string
1128
	 */
1129
	public static function makeMessageSetScript( $messages ) {
1130
		return Xml::encodeJsCall(
1131
			'mw.messages.set',
1132
			[ (object)$messages ],
1133
			ResourceLoader::inDebugMode()
1134
		);
1135
	}
1136
1137
	/**
1138
	 * Combines an associative array mapping media type to CSS into a
1139
	 * single stylesheet with "@media" blocks.
1140
	 *
1141
	 * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings
1142
	 * @return array
1143
	 */
1144
	public static function makeCombinedStyles( array $stylePairs ) {
1145
		$out = [];
1146
		foreach ( $stylePairs as $media => $styles ) {
1147
			// ResourceLoaderFileModule::getStyle can return the styles
1148
			// as a string or an array of strings. This is to allow separation in
1149
			// the front-end.
1150
			$styles = (array)$styles;
1151
			foreach ( $styles as $style ) {
1152
				$style = trim( $style );
1153
				// Don't output an empty "@media print { }" block (bug 40498)
1154
				if ( $style !== '' ) {
1155
					// Transform the media type based on request params and config
1156
					// The way that this relies on $wgRequest to propagate request params is slightly evil
1157
					$media = OutputPage::transformCssMedia( $media );
1158
1159
					if ( $media === '' || $media == 'all' ) {
1160
						$out[] = $style;
1161
					} elseif ( is_string( $media ) ) {
1162
						$out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1163
					}
1164
					// else: skip
1165
				}
1166
			}
1167
		}
1168
		return $out;
1169
	}
1170
1171
	/**
1172
	 * Returns a JS call to mw.loader.state, which sets the state of a
1173
	 * module or modules to a given value. Has two calling conventions:
1174
	 *
1175
	 *    - ResourceLoader::makeLoaderStateScript( $name, $state ):
1176
	 *         Set the state of a single module called $name to $state
1177
	 *
1178
	 *    - ResourceLoader::makeLoaderStateScript( array( $name => $state, ... ) ):
1179
	 *         Set the state of modules with the given names to the given states
1180
	 *
1181
	 * @param string $name
1182
	 * @param string $state
1183
	 * @return string
1184
	 */
1185 View Code Duplication
	public static function makeLoaderStateScript( $name, $state = null ) {
1186
		if ( is_array( $name ) ) {
1187
			return Xml::encodeJsCall(
1188
				'mw.loader.state',
1189
				[ $name ],
1190
				ResourceLoader::inDebugMode()
1191
			);
1192
		} else {
1193
			return Xml::encodeJsCall(
1194
				'mw.loader.state',
1195
				[ $name, $state ],
1196
				ResourceLoader::inDebugMode()
1197
			);
1198
		}
1199
	}
1200
1201
	/**
1202
	 * Returns JS code which calls the script given by $script. The script will
1203
	 * be called with local variables name, version, dependencies and group,
1204
	 * which will have values corresponding to $name, $version, $dependencies
1205
	 * and $group as supplied.
1206
	 *
1207
	 * @param string $name Module name
1208
	 * @param string $version Module version hash
1209
	 * @param array $dependencies List of module names on which this module depends
1210
	 * @param string $group Group which the module is in.
1211
	 * @param string $source Source of the module, or 'local' if not foreign.
1212
	 * @param string $script JavaScript code
1213
	 * @return string
1214
	 */
1215
	public static function makeCustomLoaderScript( $name, $version, $dependencies,
1216
		$group, $source, $script
1217
	) {
1218
		$script = str_replace( "\n", "\n\t", trim( $script ) );
1219
		return Xml::encodeJsCall(
1220
			"( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
1221
			[ $name, $version, $dependencies, $group, $source ],
1222
			ResourceLoader::inDebugMode()
1223
		);
1224
	}
1225
1226
	private static function isEmptyObject( stdClass $obj ) {
1227
		foreach ( $obj as $key => $value ) {
0 ignored issues
show
Bug introduced by
The expression $obj of type object<stdClass> is not traversable.
Loading history...
1228
			return false;
1229
		}
1230
		return true;
1231
	}
1232
1233
	/**
1234
	 * Remove empty values from the end of an array.
1235
	 *
1236
	 * Values considered empty:
1237
	 *
1238
	 * - null
1239
	 * - array()
1240
	 * - new XmlJsCode( '{}' )
1241
	 * - new stdClass() // (object) array()
1242
	 *
1243
	 * @param Array $array
1244
	 */
1245
	private static function trimArray( array &$array ) {
1246
		$i = count( $array );
1247
		while ( $i-- ) {
1248
			if ( $array[$i] === null
1249
				|| $array[$i] === []
1250
				|| ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1251
				|| ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1252
			) {
1253
				unset( $array[$i] );
1254
			} else {
1255
				break;
1256
			}
1257
		}
1258
	}
1259
1260
	/**
1261
	 * Returns JS code which calls mw.loader.register with the given
1262
	 * parameters. Has three calling conventions:
1263
	 *
1264
	 *   - ResourceLoader::makeLoaderRegisterScript( $name, $version,
1265
	 *        $dependencies, $group, $source, $skip
1266
	 *     ):
1267
	 *        Register a single module.
1268
	 *
1269
	 *   - ResourceLoader::makeLoaderRegisterScript( array( $name1, $name2 ) ):
1270
	 *        Register modules with the given names.
1271
	 *
1272
	 *   - ResourceLoader::makeLoaderRegisterScript( array(
1273
	 *        array( $name1, $version1, $dependencies1, $group1, $source1, $skip1 ),
1274
	 *        array( $name2, $version2, $dependencies1, $group2, $source2, $skip2 ),
1275
	 *        ...
1276
	 *     ) ):
1277
	 *        Registers modules with the given names and parameters.
1278
	 *
1279
	 * @param string $name Module name
1280
	 * @param string $version Module version hash
1281
	 * @param array $dependencies List of module names on which this module depends
1282
	 * @param string $group Group which the module is in
1283
	 * @param string $source Source of the module, or 'local' if not foreign
1284
	 * @param string $skip Script body of the skip function
1285
	 * @return string
1286
	 */
1287
	public static function makeLoaderRegisterScript( $name, $version = null,
1288
		$dependencies = null, $group = null, $source = null, $skip = null
1289
	) {
1290
		if ( is_array( $name ) ) {
1291
			// Build module name index
1292
			$index = [];
1293
			foreach ( $name as $i => &$module ) {
1294
				$index[$module[0]] = $i;
1295
			}
1296
1297
			// Transform dependency names into indexes when possible, they will be resolved by
1298
			// mw.loader.register on the other end
1299
			foreach ( $name as &$module ) {
1300
				if ( isset( $module[2] ) ) {
1301
					foreach ( $module[2] as &$dependency ) {
1302
						if ( isset( $index[$dependency] ) ) {
1303
							$dependency = $index[$dependency];
1304
						}
1305
					}
1306
				}
1307
			}
1308
1309
			array_walk( $name, [ 'self', 'trimArray' ] );
1310
1311
			return Xml::encodeJsCall(
1312
				'mw.loader.register',
1313
				[ $name ],
1314
				ResourceLoader::inDebugMode()
1315
			);
1316
		} else {
1317
			$registration = [ $name, $version, $dependencies, $group, $source, $skip ];
1318
			self::trimArray( $registration );
1319
			return Xml::encodeJsCall(
1320
				'mw.loader.register',
1321
				$registration,
1322
				ResourceLoader::inDebugMode()
1323
			);
1324
		}
1325
	}
1326
1327
	/**
1328
	 * Returns JS code which calls mw.loader.addSource() with the given
1329
	 * parameters. Has two calling conventions:
1330
	 *
1331
	 *   - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
1332
	 *       Register a single source
1333
	 *
1334
	 *   - ResourceLoader::makeLoaderSourcesScript( array( $id1 => $loadUrl, $id2 => $loadUrl, ... ) );
1335
	 *       Register sources with the given IDs and properties.
1336
	 *
1337
	 * @param string $id Source ID
1338
	 * @param array $properties Source properties (see addSource())
1339
	 * @return string
1340
	 */
1341 View Code Duplication
	public static function makeLoaderSourcesScript( $id, $properties = null ) {
1342
		if ( is_array( $id ) ) {
1343
			return Xml::encodeJsCall(
1344
				'mw.loader.addSource',
1345
				[ $id ],
1346
				ResourceLoader::inDebugMode()
1347
			);
1348
		} else {
1349
			return Xml::encodeJsCall(
1350
				'mw.loader.addSource',
1351
				[ $id, $properties ],
1352
				ResourceLoader::inDebugMode()
1353
			);
1354
		}
1355
	}
1356
1357
	/**
1358
	 * Returns JS code which runs given JS code if the client-side framework is
1359
	 * present.
1360
	 *
1361
	 * @deprecated since 1.25; use makeInlineScript instead
1362
	 * @param string $script JavaScript code
1363
	 * @return string
1364
	 */
1365
	public static function makeLoaderConditionalScript( $script ) {
1366
		return '(window.RLQ=window.RLQ||[]).push(function(){' .
1367
			trim( $script ) . '});';
1368
	}
1369
1370
	/**
1371
	 * Construct an inline script tag with given JS code.
1372
	 *
1373
	 * The code will be wrapped in a closure, and it will be executed by ResourceLoader
1374
	 * only if the client has adequate support for MediaWiki JavaScript code.
1375
	 *
1376
	 * @param string $script JavaScript code
1377
	 * @return WrappedString HTML
1378
	 */
1379
	public static function makeInlineScript( $script ) {
1380
		$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...
1381
		return new WrappedString(
1382
			Html::inlineScript( $js ),
1383
			'<script>(window.RLQ=window.RLQ||[]).push(function(){',
1384
			'});</script>'
1385
		);
1386
	}
1387
1388
	/**
1389
	 * Returns JS code which will set the MediaWiki configuration array to
1390
	 * the given value.
1391
	 *
1392
	 * @param array $configuration List of configuration values keyed by variable name
1393
	 * @return string
1394
	 */
1395
	public static function makeConfigSetScript( array $configuration ) {
1396
		return Xml::encodeJsCall(
1397
			'mw.config.set',
1398
			[ $configuration ],
1399
			ResourceLoader::inDebugMode()
1400
		);
1401
	}
1402
1403
	/**
1404
	 * Convert an array of module names to a packed query string.
1405
	 *
1406
	 * For example, array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' )
1407
	 * becomes 'foo.bar,baz|bar.baz,quux'
1408
	 * @param array $modules List of module names (strings)
1409
	 * @return string Packed query string
1410
	 */
1411
	public static function makePackedModulesString( $modules ) {
1412
		$groups = []; // array( prefix => array( suffixes ) )
1413
		foreach ( $modules as $module ) {
1414
			$pos = strrpos( $module, '.' );
1415
			$prefix = $pos === false ? '' : substr( $module, 0, $pos );
1416
			$suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1417
			$groups[$prefix][] = $suffix;
1418
		}
1419
1420
		$arr = [];
1421
		foreach ( $groups as $prefix => $suffixes ) {
1422
			$p = $prefix === '' ? '' : $prefix . '.';
1423
			$arr[] = $p . implode( ',', $suffixes );
1424
		}
1425
		$str = implode( '|', $arr );
1426
		return $str;
1427
	}
1428
1429
	/**
1430
	 * Determine whether debug mode was requested
1431
	 * Order of priority is 1) request param, 2) cookie, 3) $wg setting
1432
	 * @return bool
1433
	 */
1434
	public static function inDebugMode() {
1435
		if ( self::$debugMode === null ) {
1436
			global $wgRequest, $wgResourceLoaderDebug;
1437
			self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
1438
				$wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
1439
			);
1440
		}
1441
		return self::$debugMode;
1442
	}
1443
1444
	/**
1445
	 * Reset static members used for caching.
1446
	 *
1447
	 * Global state and $wgRequest are evil, but we're using it right
1448
	 * now and sometimes we need to be able to force ResourceLoader to
1449
	 * re-evaluate the context because it has changed (e.g. in the test suite).
1450
	 */
1451
	public static function clearCache() {
1452
		self::$debugMode = null;
1453
	}
1454
1455
	/**
1456
	 * Build a load.php URL
1457
	 *
1458
	 * @since 1.24
1459
	 * @param string $source Name of the ResourceLoader source
1460
	 * @param ResourceLoaderContext $context
1461
	 * @param array $extraQuery
1462
	 * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1463
	 */
1464
	public function createLoaderURL( $source, ResourceLoaderContext $context,
1465
		$extraQuery = []
1466
	) {
1467
		$query = self::createLoaderQuery( $context, $extraQuery );
1468
		$script = $this->getLoadScript( $source );
1469
1470
		return wfAppendQuery( $script, $query );
1471
	}
1472
1473
	/**
1474
	 * Build a load.php URL
1475
	 * @deprecated since 1.24 Use createLoaderURL() instead
1476
	 * @param array $modules Array of module names (strings)
1477
	 * @param string $lang Language code
1478
	 * @param string $skin Skin name
1479
	 * @param string|null $user User name. If null, the &user= parameter is omitted
1480
	 * @param string|null $version Versioning timestamp
1481
	 * @param bool $debug Whether the request should be in debug mode
1482
	 * @param string|null $only &only= parameter
1483
	 * @param bool $printable Printable mode
1484
	 * @param bool $handheld Handheld mode
1485
	 * @param array $extraQuery Extra query parameters to add
1486
	 * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
1487
	 */
1488
	public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
1489
		$version = null, $debug = false, $only = null, $printable = false,
1490
		$handheld = false, $extraQuery = []
1491
	) {
1492
		global $wgLoadScript;
1493
1494
		$query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
1495
			$only, $printable, $handheld, $extraQuery
1496
		);
1497
1498
		return wfAppendQuery( $wgLoadScript, $query );
1499
	}
1500
1501
	/**
1502
	 * Helper for createLoaderURL()
1503
	 *
1504
	 * @since 1.24
1505
	 * @see makeLoaderQuery
1506
	 * @param ResourceLoaderContext $context
1507
	 * @param array $extraQuery
1508
	 * @return array
1509
	 */
1510
	public static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
1511
		return self::makeLoaderQuery(
1512
			$context->getModules(),
1513
			$context->getLanguage(),
1514
			$context->getSkin(),
1515
			$context->getUser(),
1516
			$context->getVersion(),
1517
			$context->getDebug(),
1518
			$context->getOnly(),
1519
			$context->getRequest()->getBool( 'printable' ),
1520
			$context->getRequest()->getBool( 'handheld' ),
1521
			$extraQuery
1522
		);
1523
	}
1524
1525
	/**
1526
	 * Build a query array (array representation of query string) for load.php. Helper
1527
	 * function for makeLoaderURL().
1528
	 *
1529
	 * @param array $modules
1530
	 * @param string $lang
1531
	 * @param string $skin
1532
	 * @param string $user
1533
	 * @param string $version
1534
	 * @param bool $debug
1535
	 * @param string $only
1536
	 * @param bool $printable
1537
	 * @param bool $handheld
1538
	 * @param array $extraQuery
1539
	 *
1540
	 * @return array
1541
	 */
1542
	public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
1543
		$version = null, $debug = false, $only = null, $printable = false,
1544
		$handheld = false, $extraQuery = []
1545
	) {
1546
		$query = [
1547
			'modules' => self::makePackedModulesString( $modules ),
1548
			'lang' => $lang,
1549
			'skin' => $skin,
1550
			'debug' => $debug ? 'true' : 'false',
1551
		];
1552
		if ( $user !== null ) {
1553
			$query['user'] = $user;
1554
		}
1555
		if ( $version !== null ) {
1556
			$query['version'] = $version;
1557
		}
1558
		if ( $only !== null ) {
1559
			$query['only'] = $only;
1560
		}
1561
		if ( $printable ) {
1562
			$query['printable'] = 1;
1563
		}
1564
		if ( $handheld ) {
1565
			$query['handheld'] = 1;
1566
		}
1567
		$query += $extraQuery;
1568
1569
		// Make queries uniform in order
1570
		ksort( $query );
1571
		return $query;
1572
	}
1573
1574
	/**
1575
	 * Check a module name for validity.
1576
	 *
1577
	 * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
1578
	 * at most 255 bytes.
1579
	 *
1580
	 * @param string $moduleName Module name to check
1581
	 * @return bool Whether $moduleName is a valid module name
1582
	 */
1583
	public static function isValidModuleName( $moduleName ) {
1584
		return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
1585
	}
1586
1587
	/**
1588
	 * Returns LESS compiler set up for use with MediaWiki
1589
	 *
1590
	 * @since 1.27
1591
	 * @param array $extraVars Associative array of extra (i.e., other than the
1592
	 *   globally-configured ones) that should be used for compilation.
1593
	 * @throws MWException
1594
	 * @return Less_Parser
1595
	 */
1596
	public function getLessCompiler( $extraVars = [] ) {
1597
		// When called from the installer, it is possible that a required PHP extension
1598
		// is missing (at least for now; see bug 47564). If this is the case, throw an
1599
		// exception (caught by the installer) to prevent a fatal error later on.
1600
		if ( !class_exists( 'Less_Parser' ) ) {
1601
			throw new MWException( 'MediaWiki requires the less.php parser' );
1602
		}
1603
1604
		$parser = new Less_Parser;
1605
		$parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
1606
		$parser->SetImportDirs(
1607
			array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
1608
		);
1609
		$parser->SetOption( 'relativeUrls', false );
1610
		$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...
1611
1612
		return $parser;
1613
	}
1614
1615
	/**
1616
	 * Get global LESS variables.
1617
	 *
1618
	 * @since 1.27
1619
	 * @return array Map of variable names to string CSS values.
1620
	 */
1621
	public function getLessVars() {
1622
		if ( !$this->lessVars ) {
1623
			$lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
1624
			Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
1625
			$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...
1626
		}
1627
		return $this->lessVars;
1628
	}
1629
}
1630