Issues (4122)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

includes/resourceloader/ResourceLoader.php (13 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
	protected $config;
60
61
	/**
62
	 * Associative array mapping framework ids to a list of names of test suite modules
63
	 * like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ]
64
	 * @var array
65
	 */
66
	protected $testModuleNames = [];
67
68
	/**
69
	 * E.g. [ '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_REPLICA );
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 ) {
127
			$module = $this->getModule( $row->md_module );
0 ignored issues
show
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
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
		// Batched version of ResourceLoaderWikiModule::getTitleInfo
144
		ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
0 ignored issues
show
It seems like $dbr defined by wfGetDB(DB_REPLICA) on line 112 can be null; however, ResourceLoaderWikiModule::preloadTitleInfo() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
145
146
		// Prime in-object cache for message blobs for modules with messages
147
		$modules = [];
148
		foreach ( $moduleNames as $name ) {
149
			$module = $this->getModule( $name );
0 ignored issues
show
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...
150
			if ( $module && $module->getMessages() ) {
151
				$modules[$name] = $module;
152
			}
153
		}
154
		$store = $this->getMessageBlobStore();
155
		$blobs = $store->getBlobs( $modules, $lang );
156
		foreach ( $blobs as $name => $blob ) {
157
			$modules[$name]->setMessageBlob( $blob, $lang );
158
		}
159
	}
160
161
	/**
162
	 * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
163
	 *
164
	 * Available filters are:
165
	 *
166
	 *    - minify-js \see JavaScriptMinifier::minify
167
	 *    - minify-css \see CSSMin::minify
168
	 *
169
	 * If $data is empty, only contains whitespace or the filter was unknown,
170
	 * $data is returned unmodified.
171
	 *
172
	 * @param string $filter Name of filter to run
173
	 * @param string $data Text to filter, such as JavaScript or CSS text
174
	 * @param array $options Keys:
175
	 *  - (bool) cache: Whether to allow caching this data. Default: true.
176
	 * @return string Filtered data, or a comment containing an error message
177
	 */
178
	public static function filter( $filter, $data, array $options = [] ) {
179
		if ( strpos( $data, ResourceLoader::FILTER_NOMIN ) !== false ) {
180
			return $data;
181
		}
182
183
		if ( isset( $options['cache'] ) && $options['cache'] === false ) {
184
			return self::applyFilter( $filter, $data );
185
		}
186
187
		$stats = RequestContext::getMain()->getStats();
188
		$cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
189
190
		$key = $cache->makeGlobalKey(
191
			'resourceloader',
192
			'filter',
193
			$filter,
194
			self::$filterCacheVersion, md5( $data )
195
		);
196
197
		$result = $cache->get( $key );
198
		if ( $result === false ) {
199
			$stats->increment( "resourceloader_cache.$filter.miss" );
200
			$result = self::applyFilter( $filter, $data );
201
			$cache->set( $key, $result, 24 * 3600 );
202
		} else {
203
			$stats->increment( "resourceloader_cache.$filter.hit" );
204
		}
205
		if ( $result === null ) {
206
			// Cached failure
207
			$result = $data;
208
		}
209
210
		return $result;
211
	}
212
213
	private static function applyFilter( $filter, $data ) {
214
		$data = trim( $data );
215
		if ( $data ) {
216
			try {
217
				$data = ( $filter === 'minify-css' )
218
					? CSSMin::minify( $data )
219
					: JavaScriptMinifier::minify( $data );
220
			} catch ( Exception $e ) {
221
				MWExceptionHandler::logException( $e );
222
				return null;
223
			}
224
		}
225
		return $data;
226
	}
227
228
	/* Methods */
229
230
	/**
231
	 * Register core modules and runs registration hooks.
232
	 * @param Config $config [optional]
233
	 * @param LoggerInterface $logger [optional]
234
	 */
235
	public function __construct( Config $config = null, LoggerInterface $logger = null ) {
236
		global $IP;
237
238
		$this->logger = $logger ?: new NullLogger();
239
240
		if ( !$config ) {
241
			$this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
242
			$config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
243
		}
244
		$this->config = $config;
245
246
		// Add 'local' source first
247
		$this->addSource( 'local', $config->get( 'LoadScript' ) );
248
249
		// Add other sources
250
		$this->addSource( $config->get( 'ResourceLoaderSources' ) );
251
252
		// Register core modules
253
		$this->register( include "$IP/resources/Resources.php" );
254
		$this->register( include "$IP/resources/ResourcesOOUI.php" );
255
		// Register extension modules
256
		$this->register( $config->get( 'ResourceModules' ) );
257
		Hooks::run( 'ResourceLoaderRegisterModules', [ &$this ] );
258
259
		if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
260
			$this->registerTestModules();
261
		}
262
263
		$this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
264
	}
265
266
	/**
267
	 * @return Config
268
	 */
269
	public function getConfig() {
270
		return $this->config;
271
	}
272
273
	/**
274
	 * @since 1.26
275
	 * @param LoggerInterface $logger
276
	 */
277
	public function setLogger( LoggerInterface $logger ) {
278
		$this->logger = $logger;
279
	}
280
281
	/**
282
	 * @since 1.27
283
	 * @return LoggerInterface
284
	 */
285
	public function getLogger() {
286
		return $this->logger;
287
	}
288
289
	/**
290
	 * @since 1.26
291
	 * @return MessageBlobStore
292
	 */
293
	public function getMessageBlobStore() {
294
		return $this->blobStore;
295
	}
296
297
	/**
298
	 * @since 1.25
299
	 * @param MessageBlobStore $blobStore
300
	 */
301
	public function setMessageBlobStore( MessageBlobStore $blobStore ) {
302
		$this->blobStore = $blobStore;
303
	}
304
305
	/**
306
	 * Register a module with the ResourceLoader system.
307
	 *
308
	 * @param mixed $name Name of module as a string or List of name/object pairs as an array
309
	 * @param array $info Module info array. For backwards compatibility with 1.17alpha,
310
	 *   this may also be a ResourceLoaderModule object. Optional when using
311
	 *   multiple-registration calling style.
312
	 * @throws MWException If a duplicate module registration is attempted
313
	 * @throws MWException If a module name contains illegal characters (pipes or commas)
314
	 * @throws MWException If something other than a ResourceLoaderModule is being registered
315
	 * @return bool False if there were any errors, in which case one or more modules were
316
	 *   not registered
317
	 */
318
	public function register( $name, $info = null ) {
319
		$moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' );
320
321
		// Allow multiple modules to be registered in one call
322
		$registrations = is_array( $name ) ? $name : [ $name => $info ];
323
		foreach ( $registrations as $name => $info ) {
324
			// Warn on duplicate registrations
325
			if ( isset( $this->moduleInfos[$name] ) ) {
326
				// A module has already been registered by this name
327
				$this->logger->warning(
328
					'ResourceLoader duplicate registration warning. ' .
329
					'Another module has already been registered as ' . $name
330
				);
331
			}
332
333
			// Check $name for validity
334
			if ( !self::isValidModuleName( $name ) ) {
335
				throw new MWException( "ResourceLoader module name '$name' is invalid, "
336
					. "see ResourceLoader::isValidModuleName()" );
337
			}
338
339
			// Attach module
340
			if ( $info instanceof ResourceLoaderModule ) {
341
				$this->moduleInfos[$name] = [ 'object' => $info ];
342
				$info->setName( $name );
343
				$this->modules[$name] = $info;
344
			} elseif ( is_array( $info ) ) {
345
				// New calling convention
346
				$this->moduleInfos[$name] = $info;
347
			} else {
348
				throw new MWException(
349
					'ResourceLoader module info type error for module \'' . $name .
350
					'\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
351
				);
352
			}
353
354
			// Last-minute changes
355
356
			// Apply custom skin-defined styles to existing modules.
357
			if ( $this->isFileModule( $name ) ) {
358
				foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
359
					// If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
360
					if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
361
						continue;
362
					}
363
364
					// If $name is preceded with a '+', the defined style files will be added to 'default'
365
					// skinStyles, otherwise 'default' will be ignored as it normally would be.
366
					if ( isset( $skinStyles[$name] ) ) {
367
						$paths = (array)$skinStyles[$name];
368
						$styleFiles = [];
369
					} elseif ( isset( $skinStyles['+' . $name] ) ) {
370
						$paths = (array)$skinStyles['+' . $name];
371
						$styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
372
							(array)$this->moduleInfos[$name]['skinStyles']['default'] :
373
							[];
374
					} else {
375
						continue;
376
					}
377
378
					// Add new file paths, remapping them to refer to our directories and not use settings
379
					// from the module we're modifying, which come from the base definition.
380
					list( $localBasePath, $remoteBasePath ) =
381
						ResourceLoaderFileModule::extractBasePaths( $skinStyles );
382
383
					foreach ( $paths as $path ) {
384
						$styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
385
					}
386
387
					$this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
388
				}
389
			}
390
		}
391
	}
392
393
	/**
394
	 */
395
	public function registerTestModules() {
396
		global $IP;
397
398
		if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
399
			throw new MWException( 'Attempt to register JavaScript test modules '
400
				. 'but <code>$wgEnableJavaScriptTest</code> is false. '
401
				. 'Edit your <code>LocalSettings.php</code> to enable it.' );
402
		}
403
404
		// Get core test suites
405
		$testModules = [];
406
		$testModules['qunit'] = [];
407
		// Get other test suites (e.g. from extensions)
408
		Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$this ] );
409
410
		// Add the testrunner (which configures QUnit) to the dependencies.
411
		// Since it must be ready before any of the test suites are executed.
412
		foreach ( $testModules['qunit'] as &$module ) {
413
			// Make sure all test modules are top-loading so that when QUnit starts
414
			// on document-ready, it will run once and finish. If some tests arrive
415
			// later (possibly after QUnit has already finished) they will be ignored.
416
			$module['position'] = 'top';
417
			$module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
418
		}
419
420
		$testModules['qunit'] =
421
			( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
422
423
		foreach ( $testModules as $id => $names ) {
424
			// Register test modules
425
			$this->register( $testModules[$id] );
426
427
			// Keep track of their names so that they can be loaded together
428
			$this->testModuleNames[$id] = array_keys( $testModules[$id] );
429
		}
430
	}
431
432
	/**
433
	 * Add a foreign source of modules.
434
	 *
435
	 * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
436
	 *
437
	 * @param array|string $id Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
438
	 * @param string|array $loadUrl load.php url (string), or array with loadUrl key for
439
	 *  backwards-compatibility.
440
	 * @throws MWException
441
	 */
442
	public function addSource( $id, $loadUrl = null ) {
443
		// Allow multiple sources to be registered in one call
444
		if ( is_array( $id ) ) {
445
			foreach ( $id as $key => $value ) {
446
				$this->addSource( $key, $value );
447
			}
448
			return;
449
		}
450
451
		// Disallow duplicates
452
		if ( isset( $this->sources[$id] ) ) {
453
			throw new MWException(
454
				'ResourceLoader duplicate source addition error. ' .
455
				'Another source has already been registered as ' . $id
456
			);
457
		}
458
459
		// Pre 1.24 backwards-compatibility
460
		if ( is_array( $loadUrl ) ) {
461
			if ( !isset( $loadUrl['loadScript'] ) ) {
462
				throw new MWException(
463
					__METHOD__ . ' was passed an array with no "loadScript" key.'
464
				);
465
			}
466
467
			$loadUrl = $loadUrl['loadScript'];
468
		}
469
470
		$this->sources[$id] = $loadUrl;
471
	}
472
473
	/**
474
	 * Get a list of module names.
475
	 *
476
	 * @return array List of module names
477
	 */
478
	public function getModuleNames() {
479
		return array_keys( $this->moduleInfos );
480
	}
481
482
	/**
483
	 * Get a list of test module names for one (or all) frameworks.
484
	 *
485
	 * If the given framework id is unknkown, or if the in-object variable is not an array,
486
	 * then it will return an empty array.
487
	 *
488
	 * @param string $framework Get only the test module names for one
489
	 *   particular framework (optional)
490
	 * @return array
491
	 */
492
	public function getTestModuleNames( $framework = 'all' ) {
493
		/** @todo api siteinfo prop testmodulenames modulenames */
494
		if ( $framework == 'all' ) {
495
			return $this->testModuleNames;
496
		} elseif ( isset( $this->testModuleNames[$framework] )
497
			&& is_array( $this->testModuleNames[$framework] )
498
		) {
499
			return $this->testModuleNames[$framework];
500
		} else {
501
			return [];
502
		}
503
	}
504
505
	/**
506
	 * Check whether a ResourceLoader module is registered
507
	 *
508
	 * @since 1.25
509
	 * @param string $name
510
	 * @return bool
511
	 */
512
	public function isModuleRegistered( $name ) {
513
		return isset( $this->moduleInfos[$name] );
514
	}
515
516
	/**
517
	 * Get the ResourceLoaderModule object for a given module name.
518
	 *
519
	 * If an array of module parameters exists but a ResourceLoaderModule object has not
520
	 * yet been instantiated, this method will instantiate and cache that object such that
521
	 * subsequent calls simply return the same object.
522
	 *
523
	 * @param string $name Module name
524
	 * @return ResourceLoaderModule|null If module has been registered, return a
525
	 *  ResourceLoaderModule instance. Otherwise, return null.
526
	 */
527
	public function getModule( $name ) {
528
		if ( !isset( $this->modules[$name] ) ) {
529
			if ( !isset( $this->moduleInfos[$name] ) ) {
530
				// No such module
531
				return null;
532
			}
533
			// Construct the requested object
534
			$info = $this->moduleInfos[$name];
535
			/** @var ResourceLoaderModule $object */
536
			if ( isset( $info['object'] ) ) {
537
				// Object given in info array
538
				$object = $info['object'];
539
			} else {
540
				if ( !isset( $info['class'] ) ) {
541
					$class = 'ResourceLoaderFileModule';
542
				} else {
543
					$class = $info['class'];
544
				}
545
				/** @var ResourceLoaderModule $object */
546
				$object = new $class( $info );
547
				$object->setConfig( $this->getConfig() );
548
				$object->setLogger( $this->logger );
549
			}
550
			$object->setName( $name );
551
			$this->modules[$name] = $object;
552
		}
553
554
		return $this->modules[$name];
555
	}
556
557
	/**
558
	 * Return whether the definition of a module corresponds to a simple ResourceLoaderFileModule.
559
	 *
560
	 * @param string $name Module name
561
	 * @return bool
562
	 */
563
	protected function isFileModule( $name ) {
564
		if ( !isset( $this->moduleInfos[$name] ) ) {
565
			return false;
566
		}
567
		$info = $this->moduleInfos[$name];
568
		if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
569
			return false;
570
		}
571
		return true;
572
	}
573
574
	/**
575
	 * Get the list of sources.
576
	 *
577
	 * @return array Like [ id => load.php url, ... ]
578
	 */
579
	public function getSources() {
580
		return $this->sources;
581
	}
582
583
	/**
584
	 * Get the URL to the load.php endpoint for the given
585
	 * ResourceLoader source
586
	 *
587
	 * @since 1.24
588
	 * @param string $source
589
	 * @throws MWException On an invalid $source name
590
	 * @return string
591
	 */
592
	public function getLoadScript( $source ) {
593
		if ( !isset( $this->sources[$source] ) ) {
594
			throw new MWException( "The $source source was never registered in ResourceLoader." );
595
		}
596
		return $this->sources[$source];
597
	}
598
599
	/**
600
	 * @since 1.26
601
	 * @param string $value
602
	 * @return string Hash
603
	 */
604
	public static function makeHash( $value ) {
605
		$hash = hash( 'fnv132', $value );
606
		return Wikimedia\base_convert( $hash, 16, 36, 7 );
607
	}
608
609
	/**
610
	 * Helper method to get and combine versions of multiple modules.
611
	 *
612
	 * @since 1.26
613
	 * @param ResourceLoaderContext $context
614
	 * @param string[] $modules List of known module names
0 ignored issues
show
There is no parameter named $modules. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
615
	 * @return string Hash
616
	 */
617
	public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
618
		if ( !$moduleNames ) {
619
			return '';
620
		}
621
		$hashes = array_map( function ( $module ) use ( $context ) {
622
			return $this->getModule( $module )->getVersionHash( $context );
623
		}, $moduleNames );
624
		return self::makeHash( implode( '', $hashes ) );
625
	}
626
627
	/**
628
	 * Get the expected value of the 'version' query parameter.
629
	 *
630
	 * This is used by respond() to set a short Cache-Control header for requests with
631
	 * information newer than the current server has. This avoids pollution of edge caches.
632
	 * Typically during deployment. (T117587)
633
	 *
634
	 * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
635
	 *
636
	 * @since 1.28
637
	 * @param ResourceLoaderContext $context
638
	 * @param string[] $modules List of module names
0 ignored issues
show
There is no parameter named $modules. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

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