ExtensionRegistry.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. <?php
  2. use Composer\Semver\Semver;
  3. use Wikimedia\AtEase\AtEase;
  4. use Wikimedia\ScopedCallback;
  5. use MediaWiki\Shell\Shell;
  6. use MediaWiki\ShellDisabledError;
  7. /**
  8. * ExtensionRegistry class
  9. *
  10. * The Registry loads JSON files, and uses a Processor
  11. * to extract information from them. It also registers
  12. * classes with the autoloader.
  13. *
  14. * @since 1.25
  15. */
  16. class ExtensionRegistry {
  17. /**
  18. * "requires" key that applies to MediaWiki core/$wgVersion
  19. */
  20. const MEDIAWIKI_CORE = 'MediaWiki';
  21. /**
  22. * Version of the highest supported manifest version
  23. * Note: Update MANIFEST_VERSION_MW_VERSION when changing this
  24. */
  25. const MANIFEST_VERSION = 2;
  26. /**
  27. * MediaWiki version constraint representing what the current
  28. * highest MANIFEST_VERSION is supported in
  29. */
  30. const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
  31. /**
  32. * Version of the oldest supported manifest version
  33. */
  34. const OLDEST_MANIFEST_VERSION = 1;
  35. /**
  36. * Bump whenever the registration cache needs resetting
  37. */
  38. const CACHE_VERSION = 7;
  39. /**
  40. * Special key that defines the merge strategy
  41. *
  42. * @since 1.26
  43. */
  44. const MERGE_STRATEGY = '_merge_strategy';
  45. /**
  46. * Array of loaded things, keyed by name, values are credits information
  47. *
  48. * @var array
  49. */
  50. private $loaded = [];
  51. /**
  52. * List of paths that should be loaded
  53. *
  54. * @var array
  55. */
  56. protected $queued = [];
  57. /**
  58. * Whether we are done loading things
  59. *
  60. * @var bool
  61. */
  62. private $finished = false;
  63. /**
  64. * Items in the JSON file that aren't being
  65. * set as globals
  66. *
  67. * @var array
  68. */
  69. protected $attributes = [];
  70. /**
  71. * Attributes for testing
  72. *
  73. * @var array
  74. */
  75. protected $testAttributes = [];
  76. /**
  77. * Whether to check dev-requires
  78. *
  79. * @var bool
  80. */
  81. protected $checkDev = false;
  82. /**
  83. * @var ExtensionRegistry
  84. */
  85. private static $instance;
  86. /**
  87. * @codeCoverageIgnore
  88. * @return ExtensionRegistry
  89. */
  90. public static function getInstance() {
  91. if ( self::$instance === null ) {
  92. self::$instance = new self();
  93. }
  94. return self::$instance;
  95. }
  96. /**
  97. * @since 1.34
  98. * @param bool $check
  99. */
  100. public function setCheckDevRequires( $check ) {
  101. $this->checkDev = $check;
  102. }
  103. /**
  104. * @param string $path Absolute path to the JSON file
  105. */
  106. public function queue( $path ) {
  107. global $wgExtensionInfoMTime;
  108. $mtime = $wgExtensionInfoMTime;
  109. if ( $mtime === false ) {
  110. AtEase::suppressWarnings();
  111. $mtime = filemtime( $path );
  112. AtEase::restoreWarnings();
  113. // @codeCoverageIgnoreStart
  114. if ( $mtime === false ) {
  115. $err = error_get_last();
  116. throw new Exception( "Unable to open file $path: {$err['message']}" );
  117. // @codeCoverageIgnoreEnd
  118. }
  119. }
  120. $this->queued[$path] = $mtime;
  121. }
  122. /**
  123. * @throws MWException If the queue is already marked as finished (no further things should
  124. * be loaded then).
  125. */
  126. public function loadFromQueue() {
  127. global $wgVersion, $wgDevelopmentWarnings, $wgObjectCaches;
  128. if ( !$this->queued ) {
  129. return;
  130. }
  131. if ( $this->finished ) {
  132. throw new MWException(
  133. "The following paths tried to load late: "
  134. . implode( ', ', array_keys( $this->queued ) )
  135. );
  136. }
  137. // A few more things to vary the cache on
  138. $versions = [
  139. 'registration' => self::CACHE_VERSION,
  140. 'mediawiki' => $wgVersion,
  141. 'abilities' => $this->getAbilities(),
  142. 'checkDev' => $this->checkDev,
  143. ];
  144. // We use a try/catch because we don't want to fail here
  145. // if $wgObjectCaches is not configured properly for APC setup
  146. try {
  147. // Avoid MediaWikiServices to prevent instantiating it before extensions have loaded
  148. $cacheId = ObjectCache::detectLocalServerCache();
  149. $cache = ObjectCache::newFromParams( $wgObjectCaches[$cacheId] );
  150. } catch ( InvalidArgumentException $e ) {
  151. $cache = new EmptyBagOStuff();
  152. }
  153. // See if this queue is in APC
  154. $key = $cache->makeKey(
  155. 'registration',
  156. md5( json_encode( $this->queued + $versions ) )
  157. );
  158. $data = $cache->get( $key );
  159. if ( $data ) {
  160. $this->exportExtractedData( $data );
  161. } else {
  162. $data = $this->readFromQueue( $this->queued );
  163. $this->exportExtractedData( $data );
  164. // Do this late since we don't want to extract it since we already
  165. // did that, but it should be cached
  166. $data['globals']['wgAutoloadClasses'] += $data['autoload'];
  167. unset( $data['autoload'] );
  168. if ( !( $data['warnings'] && $wgDevelopmentWarnings ) ) {
  169. // If there were no warnings that were shown, cache it
  170. $cache->set( $key, $data, 60 * 60 * 24 );
  171. }
  172. }
  173. $this->queued = [];
  174. }
  175. /**
  176. * Get the current load queue. Not intended to be used
  177. * outside of the installer.
  178. *
  179. * @return array
  180. */
  181. public function getQueue() {
  182. return $this->queued;
  183. }
  184. /**
  185. * Clear the current load queue. Not intended to be used
  186. * outside of the installer.
  187. */
  188. public function clearQueue() {
  189. $this->queued = [];
  190. }
  191. /**
  192. * After this is called, no more extensions can be loaded
  193. *
  194. * @since 1.29
  195. */
  196. public function finish() {
  197. $this->finished = true;
  198. }
  199. /**
  200. * Get the list of abilities and their values
  201. * @return bool[]
  202. */
  203. private function getAbilities() {
  204. return [
  205. 'shell' => !Shell::isDisabled(),
  206. ];
  207. }
  208. /**
  209. * Queries information about the software environment and constructs an appropiate version checker
  210. *
  211. * @return VersionChecker
  212. */
  213. private function buildVersionChecker() {
  214. global $wgVersion;
  215. // array to optionally specify more verbose error messages for
  216. // missing abilities
  217. $abilityErrors = [
  218. 'shell' => ( new ShellDisabledError() )->getMessage(),
  219. ];
  220. return new VersionChecker(
  221. $wgVersion,
  222. PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
  223. get_loaded_extensions(),
  224. $this->getAbilities(),
  225. $abilityErrors
  226. );
  227. }
  228. /**
  229. * Process a queue of extensions and return their extracted data
  230. *
  231. * @param array $queue keys are filenames, values are ignored
  232. * @return array extracted info
  233. * @throws Exception
  234. * @throws ExtensionDependencyError
  235. */
  236. public function readFromQueue( array $queue ) {
  237. $autoloadClasses = [];
  238. $autoloadNamespaces = [];
  239. $autoloaderPaths = [];
  240. $processor = new ExtensionProcessor();
  241. $versionChecker = $this->buildVersionChecker();
  242. $extDependencies = [];
  243. $incompatible = [];
  244. $warnings = false;
  245. foreach ( $queue as $path => $mtime ) {
  246. $json = file_get_contents( $path );
  247. if ( $json === false ) {
  248. throw new Exception( "Unable to read $path, does it exist?" );
  249. }
  250. $info = json_decode( $json, /* $assoc = */ true );
  251. if ( !is_array( $info ) ) {
  252. throw new Exception( "$path is not a valid JSON file." );
  253. }
  254. if ( !isset( $info['manifest_version'] ) ) {
  255. wfDeprecated(
  256. "{$info['name']}'s extension.json or skin.json does not have manifest_version",
  257. '1.29'
  258. );
  259. $warnings = true;
  260. // For backwards-compatability, assume a version of 1
  261. $info['manifest_version'] = 1;
  262. }
  263. $version = $info['manifest_version'];
  264. if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
  265. $incompatible[] = "$path: unsupported manifest_version: {$version}";
  266. }
  267. $dir = dirname( $path );
  268. self::exportAutoloadClassesAndNamespaces(
  269. $dir,
  270. $info,
  271. $autoloadClasses,
  272. $autoloadNamespaces
  273. );
  274. // get all requirements/dependencies for this extension
  275. $requires = $processor->getRequirements( $info, $this->checkDev );
  276. // validate the information needed and add the requirements
  277. if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
  278. $extDependencies[$info['name']] = $requires;
  279. }
  280. // Get extra paths for later inclusion
  281. $autoloaderPaths = array_merge( $autoloaderPaths,
  282. $processor->getExtraAutoloaderPaths( $dir, $info ) );
  283. // Compatible, read and extract info
  284. $processor->extractInfo( $path, $info, $version );
  285. }
  286. $data = $processor->getExtractedInfo();
  287. $data['warnings'] = $warnings;
  288. // check for incompatible extensions
  289. $incompatible = array_merge(
  290. $incompatible,
  291. $versionChecker
  292. ->setLoadedExtensionsAndSkins( $data['credits'] )
  293. ->checkArray( $extDependencies )
  294. );
  295. if ( $incompatible ) {
  296. throw new ExtensionDependencyError( $incompatible );
  297. }
  298. // Need to set this so we can += to it later
  299. $data['globals']['wgAutoloadClasses'] = [];
  300. $data['autoload'] = $autoloadClasses;
  301. $data['autoloaderPaths'] = $autoloaderPaths;
  302. $data['autoloaderNS'] = $autoloadNamespaces;
  303. return $data;
  304. }
  305. /**
  306. * Export autoload classes and namespaces for a given directory and parsed JSON info file.
  307. *
  308. * @param string $dir
  309. * @param array $info
  310. * @param array &$autoloadClasses
  311. * @param array &$autoloadNamespaces
  312. */
  313. public static function exportAutoloadClassesAndNamespaces(
  314. $dir, $info, &$autoloadClasses = [], &$autoloadNamespaces = []
  315. ) {
  316. if ( isset( $info['AutoloadClasses'] ) ) {
  317. $autoload = self::processAutoLoader( $dir, $info['AutoloadClasses'] );
  318. $GLOBALS['wgAutoloadClasses'] += $autoload;
  319. $autoloadClasses += $autoload;
  320. }
  321. if ( isset( $info['AutoloadNamespaces'] ) ) {
  322. $autoloadNamespaces += self::processAutoLoader( $dir, $info['AutoloadNamespaces'] );
  323. AutoLoader::$psr4Namespaces += $autoloadNamespaces;
  324. }
  325. }
  326. protected function exportExtractedData( array $info ) {
  327. foreach ( $info['globals'] as $key => $val ) {
  328. // If a merge strategy is set, read it and remove it from the value
  329. // so it doesn't accidentally end up getting set.
  330. if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
  331. $mergeStrategy = $val[self::MERGE_STRATEGY];
  332. unset( $val[self::MERGE_STRATEGY] );
  333. } else {
  334. $mergeStrategy = 'array_merge';
  335. }
  336. // Optimistic: If the global is not set, or is an empty array, replace it entirely.
  337. // Will be O(1) performance.
  338. if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
  339. $GLOBALS[$key] = $val;
  340. continue;
  341. }
  342. if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
  343. // config setting that has already been overridden, don't set it
  344. continue;
  345. }
  346. switch ( $mergeStrategy ) {
  347. case 'array_merge_recursive':
  348. $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
  349. break;
  350. case 'array_replace_recursive':
  351. $GLOBALS[$key] = array_replace_recursive( $GLOBALS[$key], $val );
  352. break;
  353. case 'array_plus_2d':
  354. $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
  355. break;
  356. case 'array_plus':
  357. $GLOBALS[$key] += $val;
  358. break;
  359. case 'array_merge':
  360. $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
  361. break;
  362. default:
  363. throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
  364. }
  365. }
  366. if ( isset( $info['autoloaderNS'] ) ) {
  367. AutoLoader::$psr4Namespaces += $info['autoloaderNS'];
  368. }
  369. foreach ( $info['defines'] as $name => $val ) {
  370. define( $name, $val );
  371. }
  372. foreach ( $info['autoloaderPaths'] as $path ) {
  373. if ( file_exists( $path ) ) {
  374. require_once $path;
  375. }
  376. }
  377. $this->loaded += $info['credits'];
  378. if ( $info['attributes'] ) {
  379. if ( !$this->attributes ) {
  380. $this->attributes = $info['attributes'];
  381. } else {
  382. $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
  383. }
  384. }
  385. foreach ( $info['callbacks'] as $name => $cb ) {
  386. if ( !is_callable( $cb ) ) {
  387. if ( is_array( $cb ) ) {
  388. $cb = '[ ' . implode( ', ', $cb ) . ' ]';
  389. }
  390. throw new UnexpectedValueException( "callback '$cb' is not callable" );
  391. }
  392. $cb( $info['credits'][$name] );
  393. }
  394. }
  395. /**
  396. * Loads and processes the given JSON file without delay
  397. *
  398. * If some extensions are already queued, this will load
  399. * those as well.
  400. * TODO: Remove in MediaWiki 1.35
  401. * @deprecated since 1.34, use ExtensionRegistry->queue() instead
  402. * @param string $path Absolute path to the JSON file
  403. */
  404. public function load( $path ) {
  405. wfDeprecated( __METHOD__, '1.34' );
  406. $this->loadFromQueue(); // First clear the queue
  407. $this->queue( $path );
  408. $this->loadFromQueue();
  409. }
  410. /**
  411. * Whether a thing has been loaded
  412. * @param string $name
  413. * @param string $constraint The required version constraint for this dependency
  414. * @throws LogicException if a specific contraint is asked for,
  415. * but the extension isn't versioned
  416. * @return bool
  417. */
  418. public function isLoaded( $name, $constraint = '*' ) {
  419. $isLoaded = isset( $this->loaded[$name] );
  420. if ( $constraint === '*' || !$isLoaded ) {
  421. return $isLoaded;
  422. }
  423. // if a specific constraint is requested, but no version is set, throw an exception
  424. if ( !isset( $this->loaded[$name]['version'] ) ) {
  425. $msg = "{$name} does not expose its version, but an extension or a skin"
  426. . " requires: {$constraint}.";
  427. throw new LogicException( $msg );
  428. }
  429. return SemVer::satisfies( $this->loaded[$name]['version'], $constraint );
  430. }
  431. /**
  432. * @param string $name
  433. * @return array
  434. */
  435. public function getAttribute( $name ) {
  436. return $this->testAttributes[$name] ??
  437. $this->attributes[$name] ?? [];
  438. }
  439. /**
  440. * Force override the value of an attribute during tests
  441. *
  442. * @param string $name Name of attribute to override
  443. * @param array $value Value to set
  444. * @return ScopedCallback to reset
  445. * @since 1.33
  446. */
  447. public function setAttributeForTest( $name, array $value ) {
  448. // @codeCoverageIgnoreStart
  449. if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
  450. throw new RuntimeException( __METHOD__ . ' can only be used in tests' );
  451. }
  452. // @codeCoverageIgnoreEnd
  453. if ( isset( $this->testAttributes[$name] ) ) {
  454. throw new Exception( "The attribute '$name' has already been overridden" );
  455. }
  456. $this->testAttributes[$name] = $value;
  457. return new ScopedCallback( function () use ( $name ) {
  458. unset( $this->testAttributes[$name] );
  459. } );
  460. }
  461. /**
  462. * Get information about all things
  463. *
  464. * @return array
  465. */
  466. public function getAllThings() {
  467. return $this->loaded;
  468. }
  469. /**
  470. * Fully expand autoloader paths
  471. *
  472. * @param string $dir
  473. * @param array $files
  474. * @return array
  475. */
  476. protected static function processAutoLoader( $dir, array $files ) {
  477. // Make paths absolute, relative to the JSON file
  478. foreach ( $files as &$file ) {
  479. $file = "$dir/$file";
  480. }
  481. return $files;
  482. }
  483. }