MediaWiki  master
ExtensionRegistry.php
Go to the documentation of this file.
1 <?php
2 
3 use Composer\Semver\Semver;
6 use Wikimedia\ScopedCallback;
7 
16 
20  public const MEDIAWIKI_CORE = 'MediaWiki';
21 
26  public const MANIFEST_VERSION = 2;
27 
32  public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
33 
37  public const OLDEST_MANIFEST_VERSION = 1;
38 
42  private const CACHE_VERSION = 8;
43 
44  private const CACHE_EXPIRY = 60 * 60 * 24;
45 
51  public const MERGE_STRATEGY = '_merge_strategy';
52 
56  private const LAZY_LOADED_ATTRIBUTES = [
57  'TrackingCategories',
58  'QUnitTestModules',
59  'SkinLessImportPaths',
60  ];
61 
73  private $loaded = [];
74 
80  protected $queued = [];
81 
87  private $finished = false;
88 
95  protected $attributes = [];
96 
102  protected $testAttributes = [];
103 
109  protected $lazyAttributes = [];
110 
116  private $varyHash;
117 
123  protected $checkDev = false;
124 
130  protected $loadTestClassesAndNamespaces = false;
131 
135  private static $instance;
136 
140  private $cache = null;
141 
146  public static function getInstance() {
147  if ( self::$instance === null ) {
148  self::$instance = new self();
149  }
150 
151  return self::$instance;
152  }
153 
162  public function setCache( BagOStuff $cache ): void {
163  $this->cache = $cache;
164  }
165 
170  public function setCheckDevRequires( $check ) {
171  $this->checkDev = $check;
172  $this->invalidateProcessCache();
173  }
174 
182  public function setLoadTestClassesAndNamespaces( $load ) {
183  $this->loadTestClassesAndNamespaces = $load;
184  }
185 
189  public function queue( $path ) {
190  global $wgExtensionInfoMTime;
191 
192  $mtime = $wgExtensionInfoMTime;
193  if ( $mtime === false ) {
194  // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
195  $mtime = @filemtime( $path );
196  // @codeCoverageIgnoreStart
197  if ( $mtime === false ) {
198  $err = error_get_last();
199  throw new Exception( "Unable to open file $path: {$err['message']}" );
200  // @codeCoverageIgnoreEnd
201  }
202  }
203  $this->queued[$path] = $mtime;
204  $this->invalidateProcessCache();
205  }
206 
207  private function getCache(): BagOStuff {
208  if ( !$this->cache ) {
209  // Can't call MediaWikiServices here, as we must not cause services
210  // to be instantiated before extensions have loaded.
212  }
213 
214  return $this->cache;
215  }
216 
217  private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
218  // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
219  return $cache->makeGlobalKey(
220  "registration-$component",
221  $this->getVaryHash(),
222  ...$extra
223  );
224  }
225 
231  private function getVaryHash() {
232  if ( $this->varyHash === null ) {
233  // We vary the cache on the current queue (what will be or already was loaded)
234  // plus various versions of stuff for VersionChecker
235  $vary = [
236  'registration' => self::CACHE_VERSION,
237  'mediawiki' => MW_VERSION,
238  'abilities' => $this->getAbilities(),
239  'checkDev' => $this->checkDev,
240  'queue' => $this->queued,
241  ];
242  $this->varyHash = md5( json_encode( $vary ) );
243  }
244  return $this->varyHash;
245  }
246 
250  private function invalidateProcessCache() {
251  $this->varyHash = null;
252  $this->lazyAttributes = [];
253  }
254 
259  public function loadFromQueue() {
260  if ( !$this->queued ) {
261  return;
262  }
263 
264  if ( $this->finished ) {
265  throw new MWException(
266  "The following paths tried to load late: "
267  . implode( ', ', array_keys( $this->queued ) )
268  );
269  }
270 
271  $cache = $this->getCache();
272  // See if this queue is in APC
273  $key = $this->makeCacheKey( $cache, 'main' );
274  $data = $cache->get( $key );
275  if ( !$data ) {
276  $data = $this->readFromQueue( $this->queued );
277  $this->saveToCache( $cache, $data );
278  }
279  $this->exportExtractedData( $data );
280  }
281 
288  protected function saveToCache( BagOStuff $cache, array $data ) {
289  global $wgDevelopmentWarnings;
290  if ( $data['warnings'] && $wgDevelopmentWarnings ) {
291  // If warnings were shown, don't cache it
292  return;
293  }
294  $lazy = [];
295  // Cache lazy-loaded attributes separately
296  foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
297  if ( isset( $data['attributes'][$attrib] ) ) {
298  $lazy[$attrib] = $data['attributes'][$attrib];
299  unset( $data['attributes'][$attrib] );
300  }
301  }
302  $mainKey = $this->makeCacheKey( $cache, 'main' );
303  $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
304  foreach ( $lazy as $attrib => $value ) {
305  $cache->set(
306  $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
307  $value,
308  self::CACHE_EXPIRY
309  );
310  }
311  }
312 
319  public function getQueue() {
320  return $this->queued;
321  }
322 
327  public function clearQueue() {
328  $this->queued = [];
329  $this->invalidateProcessCache();
330  }
331 
337  public function finish() {
338  $this->finished = true;
339  }
340 
345  private function getAbilities() {
346  return [
347  'shell' => !Shell::isDisabled(),
348  ];
349  }
350 
356  private function buildVersionChecker() {
357  // array to optionally specify more verbose error messages for
358  // missing abilities
359  $abilityErrors = [
360  'shell' => ( new ShellDisabledError() )->getMessage(),
361  ];
362 
363  return new VersionChecker(
364  MW_VERSION,
365  PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
366  get_loaded_extensions(),
367  $this->getAbilities(),
368  $abilityErrors
369  );
370  }
371 
382  public function readFromQueue( array $queue ) {
383  $processor = new ExtensionProcessor();
384  $versionChecker = $this->buildVersionChecker();
385  $extDependencies = [];
386  $warnings = false;
387  foreach ( $queue as $path => $mtime ) {
388  $json = file_get_contents( $path );
389  if ( $json === false ) {
390  throw new Exception( "Unable to read $path, does it exist?" );
391  }
392  $info = json_decode( $json, /* $assoc = */ true );
393  if ( !is_array( $info ) ) {
394  throw new Exception( "$path is not a valid JSON file." );
395  }
396 
397  if ( !isset( $info['manifest_version'] ) ) {
399  "{$info['name']}'s extension.json or skin.json does not have manifest_version, " .
400  'this is deprecated since MediaWiki 1.29',
401  '1.29', false, false
402  );
403  $warnings = true;
404  // For backwards-compatibility, assume a version of 1
405  $info['manifest_version'] = 1;
406  }
407  $version = $info['manifest_version'];
408  if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
409  throw new Exception( "$path: unsupported manifest_version: {$version}" );
410  }
411 
412  // get all requirements/dependencies for this extension
413  $requires = $processor->getRequirements( $info, $this->checkDev );
414 
415  // validate the information needed and add the requirements
416  if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
417  $extDependencies[$info['name']] = $requires;
418  }
419 
420  // Compatible, read and extract info
421  $processor->extractInfo( $path, $info, $version );
422  }
423  $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
424  $data['warnings'] = $warnings;
425 
426  // check for incompatible extensions
427  $incompatible = $versionChecker
428  ->setLoadedExtensionsAndSkins( $data['credits'] )
429  ->checkArray( $extDependencies );
430 
431  if ( $incompatible ) {
432  throw new ExtensionDependencyError( $incompatible );
433  }
434 
435  return $data;
436  }
437 
438  protected function exportExtractedData( array $info ) {
439  foreach ( $info['globals'] as $key => $val ) {
440  // If a merge strategy is set, read it and remove it from the value
441  // so it doesn't accidentally end up getting set.
442  if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
443  $mergeStrategy = $val[self::MERGE_STRATEGY];
444  unset( $val[self::MERGE_STRATEGY] );
445  } else {
446  $mergeStrategy = 'array_merge';
447  }
448 
449  if ( $mergeStrategy === 'provide_default' ) {
450  if ( !array_key_exists( $key, $GLOBALS ) ) {
451  $GLOBALS[$key] = $val;
452  }
453  continue;
454  }
455 
456  // Optimistic: If the global is not set, or is an empty array, replace it entirely.
457  // Will be O(1) performance.
458  if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
459  $GLOBALS[$key] = $val;
460  continue;
461  }
462 
463  if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
464  // config setting that has already been overridden, don't set it
465  continue;
466  }
467 
468  switch ( $mergeStrategy ) {
469  case 'array_merge_recursive':
470  $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
471  break;
472  case 'array_replace_recursive':
473  $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
474  break;
475  case 'array_plus_2d':
476  $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
477  break;
478  case 'array_plus':
479  $GLOBALS[$key] += $val;
480  break;
481  case 'array_merge':
482  $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
483  break;
484  default:
485  throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
486  }
487  }
488 
489  if ( isset( $info['autoloaderNS'] ) ) {
490  AutoLoader::registerNamespaces( $info['autoloaderNS'] );
491  }
492 
493  if ( isset( $info['autoloaderClasses'] ) ) {
494  AutoLoader::registerClasses( $info['autoloaderClasses'] );
495  }
496 
497  foreach ( $info['defines'] as $name => $val ) {
498  if ( !defined( $name ) ) {
499  define( $name, $val );
500  } elseif ( constant( $name ) !== $val ) {
501  throw new UnexpectedValueException(
502  "$name cannot be re-defined with $val it has already been set with " . constant( $name )
503  );
504  }
505  }
506 
507  if ( isset( $info['autoloaderPaths'] ) ) {
508  AutoLoader::loadFiles( $info['autoloaderPaths'] );
509  }
510 
511  $this->loaded += $info['credits'];
512  if ( $info['attributes'] ) {
513  if ( !$this->attributes ) {
514  $this->attributes = $info['attributes'];
515  } else {
516  $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
517  }
518  }
519 
520  foreach ( $info['callbacks'] as $name => $cb ) {
521  if ( !is_callable( $cb ) ) {
522  if ( is_array( $cb ) ) {
523  $cb = '[ ' . implode( ', ', $cb ) . ' ]';
524  }
525  throw new UnexpectedValueException( "callback '$cb' is not callable" );
526  }
527  $cb( $info['credits'][$name] );
528  }
529  }
530 
539  public function isLoaded( $name, $constraint = '*' ) {
540  $isLoaded = isset( $this->loaded[$name] );
541  if ( $constraint === '*' || !$isLoaded ) {
542  return $isLoaded;
543  }
544  // if a specific constraint is requested, but no version is set, throw an exception
545  if ( !isset( $this->loaded[$name]['version'] ) ) {
546  $msg = "{$name} does not expose its version, but an extension or a skin"
547  . " requires: {$constraint}.";
548  throw new LogicException( $msg );
549  }
550 
551  return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
552  }
553 
558  public function getAttribute( $name ) {
559  if ( isset( $this->testAttributes[$name] ) ) {
560  return $this->testAttributes[$name];
561  }
562 
563  if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
564  return $this->getLazyLoadedAttribute( $name );
565  }
566 
567  return $this->attributes[$name] ?? [];
568  }
569 
576  protected function getLazyLoadedAttribute( $name ) {
577  if ( isset( $this->testAttributes[$name] ) ) {
578  return $this->testAttributes[$name];
579  }
580  if ( isset( $this->lazyAttributes[$name] ) ) {
581  return $this->lazyAttributes[$name];
582  }
583 
584  // See if it's in the cache
585  $cache = $this->getCache();
586  $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
587  $data = $cache->get( $key );
588  if ( $data !== false ) {
589  $this->lazyAttributes[$name] = $data;
590  return $data;
591  }
592 
593  $paths = [];
594  foreach ( $this->loaded as $info ) {
595  // mtime (array value) doesn't matter here since
596  // we're skipping cache, so use a dummy time
597  $paths[$info['path']] = 1;
598  }
599 
600  $result = $this->readFromQueue( $paths );
601  $data = $result['attributes'][$name] ?? [];
602  $this->saveToCache( $cache, $result );
603  $this->lazyAttributes[$name] = $data;
604 
605  return $data;
606  }
607 
616  public function setAttributeForTest( $name, array $value ) {
617  // @codeCoverageIgnoreStart
618  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
619  throw new RuntimeException( __METHOD__ . ' can only be used in tests' );
620  }
621  // @codeCoverageIgnoreEnd
622  if ( isset( $this->testAttributes[$name] ) ) {
623  throw new Exception( "The attribute '$name' has already been overridden" );
624  }
625  $this->testAttributes[$name] = $value;
626  return new ScopedCallback( function () use ( $name ) {
627  unset( $this->testAttributes[$name] );
628  } );
629  }
630 
636  public function getAllThings() {
637  return $this->loaded;
638  }
639 
647  protected static function processAutoLoader( $dir, array $files ) {
648  // Make paths absolute, relative to the JSON file
649  foreach ( $files as &$file ) {
650  $file = "$dir/$file";
651  }
652  return $files;
653  }
654 }
const MW_VERSION
The running version of MediaWiki.
Definition: Defines.php:36
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
wfArrayPlus2d(array $baseArray, array $newValues)
Merges two (possibly) 2 dimensional arrays into the target array ($baseArray).
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:85
Copyright (C) 2018 Kunal Mehta legoktm@debian.org
Utility class for loading extension manifests and aggregating their contents.
The Registry loads JSON files, and uses a Processor to extract information from them.
setLoadTestClassesAndNamespaces( $load)
Controls if classes and namespaces defined under the keys TestAutoloadClasses and TestAutoloadNamespa...
static processAutoLoader( $dir, array $files)
Fully expand autoloader paths.
isLoaded( $name, $constraint=' *')
Whether a thing has been loaded.
const MERGE_STRATEGY
Special key that defines the merge strategy.
getQueue()
Get the current load queue.
getLazyLoadedAttribute( $name)
Get an attribute value that isn't cached by reading each extension.json file again.
saveToCache(BagOStuff $cache, array $data)
Save data in the cache.
const MANIFEST_VERSION
Version of the highest supported manifest version Note: Update MANIFEST_VERSION_MW_VERSION when chang...
setAttributeForTest( $name, array $value)
Force override the value of an attribute during tests.
const OLDEST_MANIFEST_VERSION
Version of the oldest supported manifest version.
array $testAttributes
Attributes for testing.
clearQueue()
Clear the current load queue.
array $lazyAttributes
Lazy-loaded attributes.
setCache(BagOStuff $cache)
Set the cache to use for extension info.
const MANIFEST_VERSION_MW_VERSION
MediaWiki version constraint representing what the current highest MANIFEST_VERSION is supported in.
int[] $queued
List of paths that should be loaded.
const MEDIAWIKI_CORE
"requires" key that applies to MediaWiki core
bool $loadTestClassesAndNamespaces
Whether test classes and namespaces should be added to the auto loader.
readFromQueue(array $queue)
Process a queue of extensions and return their extracted data.
exportExtractedData(array $info)
getAllThings()
Get credits information about all installed extensions and skins.
finish()
After this is called, no more extensions can be loaded.
array $attributes
Items in the JSON file that aren't being set as globals.
bool $checkDev
Whether to check dev-requires.
MediaWiki exception.
Definition: MWException.php:29
Executes shell commands.
Definition: Shell.php:46
static makeLocalServerCache()
Create a new BagOStuff instance for local-server caching.
Provides functions to check a set of extensions with dependencies against a set of loaded extensions ...
$wgExtensionInfoMTime
Config variable stub for the ExtensionInfoMTime setting, for use by phpdoc and IDEs.
$wgDevelopmentWarnings
Config variable stub for the DevelopmentWarnings setting, for use by phpdoc and IDEs.
$cache
Definition: mcc.php:33
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42