ExtensionRegistry.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <?php
  2. use MediaWiki\MediaWikiServices;
  3. /**
  4. * ExtensionRegistry class
  5. *
  6. * The Registry loads JSON files, and uses a Processor
  7. * to extract information from them. It also registers
  8. * classes with the autoloader.
  9. *
  10. * @since 1.25
  11. */
  12. class ExtensionRegistry {
  13. /**
  14. * "requires" key that applies to MediaWiki core/$wgVersion
  15. */
  16. const MEDIAWIKI_CORE = 'MediaWiki';
  17. /**
  18. * Version of the highest supported manifest version
  19. * Note: Update MANIFEST_VERSION_MW_VERSION when changing this
  20. */
  21. const MANIFEST_VERSION = 2;
  22. /**
  23. * MediaWiki version constraint representing what the current
  24. * highest MANIFEST_VERSION is supported in
  25. */
  26. const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
  27. /**
  28. * Version of the oldest supported manifest version
  29. */
  30. const OLDEST_MANIFEST_VERSION = 1;
  31. /**
  32. * Bump whenever the registration cache needs resetting
  33. */
  34. const CACHE_VERSION = 6;
  35. /**
  36. * Special key that defines the merge strategy
  37. *
  38. * @since 1.26
  39. */
  40. const MERGE_STRATEGY = '_merge_strategy';
  41. /**
  42. * Array of loaded things, keyed by name, values are credits information
  43. *
  44. * @var array
  45. */
  46. private $loaded = [];
  47. /**
  48. * List of paths that should be loaded
  49. *
  50. * @var array
  51. */
  52. protected $queued = [];
  53. /**
  54. * Whether we are done loading things
  55. *
  56. * @var bool
  57. */
  58. private $finished = false;
  59. /**
  60. * Items in the JSON file that aren't being
  61. * set as globals
  62. *
  63. * @var array
  64. */
  65. protected $attributes = [];
  66. /**
  67. * @var ExtensionRegistry
  68. */
  69. private static $instance;
  70. /**
  71. * @codeCoverageIgnore
  72. * @return ExtensionRegistry
  73. */
  74. public static function getInstance() {
  75. if ( self::$instance === null ) {
  76. self::$instance = new self();
  77. }
  78. return self::$instance;
  79. }
  80. /**
  81. * @param string $path Absolute path to the JSON file
  82. */
  83. public function queue( $path ) {
  84. global $wgExtensionInfoMTime;
  85. $mtime = $wgExtensionInfoMTime;
  86. if ( $mtime === false ) {
  87. if ( file_exists( $path ) ) {
  88. $mtime = filemtime( $path );
  89. } else {
  90. throw new Exception( "$path does not exist!" );
  91. }
  92. // @codeCoverageIgnoreStart
  93. if ( $mtime === false ) {
  94. $err = error_get_last();
  95. throw new Exception( "Couldn't stat $path: {$err['message']}" );
  96. // @codeCoverageIgnoreEnd
  97. }
  98. }
  99. $this->queued[$path] = $mtime;
  100. }
  101. /**
  102. * @throws MWException If the queue is already marked as finished (no further things should
  103. * be loaded then).
  104. */
  105. public function loadFromQueue() {
  106. global $wgVersion, $wgDevelopmentWarnings;
  107. if ( !$this->queued ) {
  108. return;
  109. }
  110. if ( $this->finished ) {
  111. throw new MWException(
  112. "The following paths tried to load late: "
  113. . implode( ', ', array_keys( $this->queued ) )
  114. );
  115. }
  116. // A few more things to vary the cache on
  117. $versions = [
  118. 'registration' => self::CACHE_VERSION,
  119. 'mediawiki' => $wgVersion
  120. ];
  121. // We use a try/catch because we don't want to fail here
  122. // if $wgObjectCaches is not configured properly for APC setup
  123. try {
  124. $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
  125. } catch ( MWException $e ) {
  126. $cache = new EmptyBagOStuff();
  127. }
  128. // See if this queue is in APC
  129. $key = $cache->makeKey(
  130. 'registration',
  131. md5( json_encode( $this->queued + $versions ) )
  132. );
  133. $data = $cache->get( $key );
  134. if ( $data ) {
  135. $this->exportExtractedData( $data );
  136. } else {
  137. $data = $this->readFromQueue( $this->queued );
  138. $this->exportExtractedData( $data );
  139. // Do this late since we don't want to extract it since we already
  140. // did that, but it should be cached
  141. $data['globals']['wgAutoloadClasses'] += $data['autoload'];
  142. unset( $data['autoload'] );
  143. if ( !( $data['warnings'] && $wgDevelopmentWarnings ) ) {
  144. // If there were no warnings that were shown, cache it
  145. $cache->set( $key, $data, 60 * 60 * 24 );
  146. }
  147. }
  148. $this->queued = [];
  149. }
  150. /**
  151. * Get the current load queue. Not intended to be used
  152. * outside of the installer.
  153. *
  154. * @return array
  155. */
  156. public function getQueue() {
  157. return $this->queued;
  158. }
  159. /**
  160. * Clear the current load queue. Not intended to be used
  161. * outside of the installer.
  162. */
  163. public function clearQueue() {
  164. $this->queued = [];
  165. }
  166. /**
  167. * After this is called, no more extensions can be loaded
  168. *
  169. * @since 1.29
  170. */
  171. public function finish() {
  172. $this->finished = true;
  173. }
  174. /**
  175. * Process a queue of extensions and return their extracted data
  176. *
  177. * @param array $queue keys are filenames, values are ignored
  178. * @return array extracted info
  179. * @throws Exception
  180. * @throws ExtensionDependencyError
  181. */
  182. public function readFromQueue( array $queue ) {
  183. global $wgVersion;
  184. $autoloadClasses = [];
  185. $autoloadNamespaces = [];
  186. $autoloaderPaths = [];
  187. $processor = new ExtensionProcessor();
  188. $versionChecker = new VersionChecker( $wgVersion );
  189. $extDependencies = [];
  190. $incompatible = [];
  191. $warnings = false;
  192. foreach ( $queue as $path => $mtime ) {
  193. $json = file_get_contents( $path );
  194. if ( $json === false ) {
  195. throw new Exception( "Unable to read $path, does it exist?" );
  196. }
  197. $info = json_decode( $json, /* $assoc = */ true );
  198. if ( !is_array( $info ) ) {
  199. throw new Exception( "$path is not a valid JSON file." );
  200. }
  201. if ( !isset( $info['manifest_version'] ) ) {
  202. wfDeprecated(
  203. "{$info['name']}'s extension.json or skin.json does not have manifest_version",
  204. '1.29'
  205. );
  206. $warnings = true;
  207. // For backwards-compatability, assume a version of 1
  208. $info['manifest_version'] = 1;
  209. }
  210. $version = $info['manifest_version'];
  211. if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
  212. $incompatible[] = "$path: unsupported manifest_version: {$version}";
  213. }
  214. $dir = dirname( $path );
  215. if ( isset( $info['AutoloadClasses'] ) ) {
  216. $autoload = $this->processAutoLoader( $dir, $info['AutoloadClasses'] );
  217. $GLOBALS['wgAutoloadClasses'] += $autoload;
  218. $autoloadClasses += $autoload;
  219. }
  220. if ( isset( $info['AutoloadNamespaces'] ) ) {
  221. $autoloadNamespaces += $this->processAutoLoader( $dir, $info['AutoloadNamespaces'] );
  222. AutoLoader::$psr4Namespaces += $autoloadNamespaces;
  223. }
  224. // get all requirements/dependencies for this extension
  225. $requires = $processor->getRequirements( $info );
  226. // validate the information needed and add the requirements
  227. if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
  228. $extDependencies[$info['name']] = $requires;
  229. }
  230. // Get extra paths for later inclusion
  231. $autoloaderPaths = array_merge( $autoloaderPaths,
  232. $processor->getExtraAutoloaderPaths( $dir, $info ) );
  233. // Compatible, read and extract info
  234. $processor->extractInfo( $path, $info, $version );
  235. }
  236. $data = $processor->getExtractedInfo();
  237. $data['warnings'] = $warnings;
  238. // check for incompatible extensions
  239. $incompatible = array_merge(
  240. $incompatible,
  241. $versionChecker
  242. ->setLoadedExtensionsAndSkins( $data['credits'] )
  243. ->checkArray( $extDependencies )
  244. );
  245. if ( $incompatible ) {
  246. throw new ExtensionDependencyError( $incompatible );
  247. }
  248. // Need to set this so we can += to it later
  249. $data['globals']['wgAutoloadClasses'] = [];
  250. $data['autoload'] = $autoloadClasses;
  251. $data['autoloaderPaths'] = $autoloaderPaths;
  252. $data['autoloaderNS'] = $autoloadNamespaces;
  253. return $data;
  254. }
  255. protected function exportExtractedData( array $info ) {
  256. foreach ( $info['globals'] as $key => $val ) {
  257. // If a merge strategy is set, read it and remove it from the value
  258. // so it doesn't accidentally end up getting set.
  259. if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
  260. $mergeStrategy = $val[self::MERGE_STRATEGY];
  261. unset( $val[self::MERGE_STRATEGY] );
  262. } else {
  263. $mergeStrategy = 'array_merge';
  264. }
  265. // Optimistic: If the global is not set, or is an empty array, replace it entirely.
  266. // Will be O(1) performance.
  267. if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
  268. $GLOBALS[$key] = $val;
  269. continue;
  270. }
  271. if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
  272. // config setting that has already been overridden, don't set it
  273. continue;
  274. }
  275. switch ( $mergeStrategy ) {
  276. case 'array_merge_recursive':
  277. $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
  278. break;
  279. case 'array_replace_recursive':
  280. $GLOBALS[$key] = array_replace_recursive( $GLOBALS[$key], $val );
  281. break;
  282. case 'array_plus_2d':
  283. $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
  284. break;
  285. case 'array_plus':
  286. $GLOBALS[$key] += $val;
  287. break;
  288. case 'array_merge':
  289. $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
  290. break;
  291. default:
  292. throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
  293. }
  294. }
  295. if ( isset( $info['autoloaderNS'] ) ) {
  296. AutoLoader::$psr4Namespaces += $info['autoloaderNS'];
  297. }
  298. foreach ( $info['defines'] as $name => $val ) {
  299. define( $name, $val );
  300. }
  301. foreach ( $info['autoloaderPaths'] as $path ) {
  302. if ( file_exists( $path ) ) {
  303. require_once $path;
  304. }
  305. }
  306. $this->loaded += $info['credits'];
  307. if ( $info['attributes'] ) {
  308. if ( !$this->attributes ) {
  309. $this->attributes = $info['attributes'];
  310. } else {
  311. $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
  312. }
  313. }
  314. foreach ( $info['callbacks'] as $name => $cb ) {
  315. if ( !is_callable( $cb ) ) {
  316. if ( is_array( $cb ) ) {
  317. $cb = '[ ' . implode( ', ', $cb ) . ' ]';
  318. }
  319. throw new UnexpectedValueException( "callback '$cb' is not callable" );
  320. }
  321. call_user_func( $cb, $info['credits'][$name] );
  322. }
  323. }
  324. /**
  325. * Loads and processes the given JSON file without delay
  326. *
  327. * If some extensions are already queued, this will load
  328. * those as well.
  329. *
  330. * @param string $path Absolute path to the JSON file
  331. */
  332. public function load( $path ) {
  333. $this->loadFromQueue(); // First clear the queue
  334. $this->queue( $path );
  335. $this->loadFromQueue();
  336. }
  337. /**
  338. * Whether a thing has been loaded
  339. * @param string $name
  340. * @return bool
  341. */
  342. public function isLoaded( $name ) {
  343. return isset( $this->loaded[$name] );
  344. }
  345. /**
  346. * @param string $name
  347. * @return array
  348. */
  349. public function getAttribute( $name ) {
  350. if ( isset( $this->attributes[$name] ) ) {
  351. return $this->attributes[$name];
  352. } else {
  353. return [];
  354. }
  355. }
  356. /**
  357. * Get information about all things
  358. *
  359. * @return array
  360. */
  361. public function getAllThings() {
  362. return $this->loaded;
  363. }
  364. /**
  365. * Fully expand autoloader paths
  366. *
  367. * @param string $dir
  368. * @param array $files
  369. * @return array
  370. */
  371. protected function processAutoLoader( $dir, array $files ) {
  372. // Make paths absolute, relative to the JSON file
  373. foreach ( $files as &$file ) {
  374. $file = "$dir/$file";
  375. }
  376. return $files;
  377. }
  378. }