ExtensionProcessor.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. <?php
  2. class ExtensionProcessor implements Processor {
  3. /**
  4. * Keys that should be set to $GLOBALS
  5. *
  6. * @var array
  7. */
  8. protected static $globalSettings = [
  9. 'ActionFilteredLogs',
  10. 'Actions',
  11. 'AddGroups',
  12. 'APIFormatModules',
  13. 'APIListModules',
  14. 'APIMetaModules',
  15. 'APIModules',
  16. 'APIPropModules',
  17. 'AuthManagerAutoConfig',
  18. 'AvailableRights',
  19. 'CentralIdLookupProviders',
  20. 'ChangeCredentialsBlacklist',
  21. 'ConfigRegistry',
  22. 'ContentHandlers',
  23. 'DefaultUserOptions',
  24. 'ExtensionEntryPointListFiles',
  25. 'ExtensionFunctions',
  26. 'FeedClasses',
  27. 'FileExtensions',
  28. 'FilterLogTypes',
  29. 'GrantPermissionGroups',
  30. 'GrantPermissions',
  31. 'GroupPermissions',
  32. 'GroupsAddToSelf',
  33. 'GroupsRemoveFromSelf',
  34. 'HiddenPrefs',
  35. 'ImplicitGroups',
  36. 'JobClasses',
  37. 'LogActions',
  38. 'LogActionsHandlers',
  39. 'LogHeaders',
  40. 'LogNames',
  41. 'LogRestrictions',
  42. 'LogTypes',
  43. 'MediaHandlers',
  44. 'PasswordPolicy',
  45. 'RateLimits',
  46. 'RawHtmlMessages',
  47. 'ReauthenticateTime',
  48. 'RecentChangesFlags',
  49. 'RemoveCredentialsBlacklist',
  50. 'RemoveGroups',
  51. 'ResourceLoaderSources',
  52. 'RevokePermissions',
  53. 'SessionProviders',
  54. 'SpecialPages',
  55. 'ValidSkinNames',
  56. ];
  57. /**
  58. * Top-level attributes that come from MW core
  59. *
  60. * @var string[]
  61. */
  62. protected static $coreAttributes = [
  63. 'SkinOOUIThemes',
  64. 'TrackingCategories',
  65. 'RestRoutes',
  66. ];
  67. /**
  68. * Mapping of global settings to their specific merge strategies.
  69. *
  70. * @see ExtensionRegistry::exportExtractedData
  71. * @see getExtractedInfo
  72. * @var array
  73. */
  74. protected static $mergeStrategies = [
  75. 'wgAuthManagerAutoConfig' => 'array_plus_2d',
  76. 'wgCapitalLinkOverrides' => 'array_plus',
  77. 'wgExtensionCredits' => 'array_merge_recursive',
  78. 'wgExtraGenderNamespaces' => 'array_plus',
  79. 'wgGrantPermissions' => 'array_plus_2d',
  80. 'wgGroupPermissions' => 'array_plus_2d',
  81. 'wgHooks' => 'array_merge_recursive',
  82. 'wgNamespaceContentModels' => 'array_plus',
  83. 'wgNamespaceProtection' => 'array_plus',
  84. 'wgNamespacesWithSubpages' => 'array_plus',
  85. 'wgPasswordPolicy' => 'array_merge_recursive',
  86. 'wgRateLimits' => 'array_plus_2d',
  87. 'wgRevokePermissions' => 'array_plus_2d',
  88. ];
  89. /**
  90. * Keys that are part of the extension credits
  91. *
  92. * @var array
  93. */
  94. protected static $creditsAttributes = [
  95. 'name',
  96. 'namemsg',
  97. 'author',
  98. 'version',
  99. 'url',
  100. 'description',
  101. 'descriptionmsg',
  102. 'license-name',
  103. ];
  104. /**
  105. * Things that are not 'attributes', and are not in
  106. * $globalSettings or $creditsAttributes.
  107. *
  108. * @var array
  109. */
  110. protected static $notAttributes = [
  111. 'callback',
  112. 'Hooks',
  113. 'namespaces',
  114. 'ResourceFileModulePaths',
  115. 'ResourceModules',
  116. 'ResourceModuleSkinStyles',
  117. 'OOUIThemePaths',
  118. 'QUnitTestModule',
  119. 'ExtensionMessagesFiles',
  120. 'MessagesDirs',
  121. 'type',
  122. 'config',
  123. 'config_prefix',
  124. 'ServiceWiringFiles',
  125. 'ParserTestFiles',
  126. 'AutoloadClasses',
  127. 'manifest_version',
  128. 'load_composer_autoloader',
  129. ];
  130. /**
  131. * Stuff that is going to be set to $GLOBALS
  132. *
  133. * Some keys are pre-set to arrays so we can += to them
  134. *
  135. * @var array
  136. */
  137. protected $globals = [
  138. 'wgExtensionMessagesFiles' => [],
  139. 'wgMessagesDirs' => [],
  140. ];
  141. /**
  142. * Things that should be define()'d
  143. *
  144. * @var array
  145. */
  146. protected $defines = [];
  147. /**
  148. * Things to be called once registration of these extensions are done
  149. * keyed by the name of the extension that it belongs to
  150. *
  151. * @var callable[]
  152. */
  153. protected $callbacks = [];
  154. /**
  155. * @var array
  156. */
  157. protected $credits = [];
  158. /**
  159. * @var array
  160. */
  161. protected $config = [];
  162. /**
  163. * Any thing else in the $info that hasn't
  164. * already been processed
  165. *
  166. * @var array
  167. */
  168. protected $attributes = [];
  169. /**
  170. * Extension attributes, keyed by name =>
  171. * settings.
  172. *
  173. * @var array
  174. */
  175. protected $extAttributes = [];
  176. /**
  177. * @param string $path
  178. * @param array $info
  179. * @param int $version manifest_version for info
  180. */
  181. public function extractInfo( $path, array $info, $version ) {
  182. $dir = dirname( $path );
  183. $this->extractHooks( $info );
  184. $this->extractExtensionMessagesFiles( $dir, $info );
  185. $this->extractMessagesDirs( $dir, $info );
  186. $this->extractNamespaces( $info );
  187. $this->extractResourceLoaderModules( $dir, $info );
  188. if ( isset( $info['ServiceWiringFiles'] ) ) {
  189. $this->extractPathBasedGlobal(
  190. 'wgServiceWiringFiles',
  191. $dir,
  192. $info['ServiceWiringFiles']
  193. );
  194. }
  195. if ( isset( $info['ParserTestFiles'] ) ) {
  196. $this->extractPathBasedGlobal(
  197. 'wgParserTestFiles',
  198. $dir,
  199. $info['ParserTestFiles']
  200. );
  201. }
  202. $name = $this->extractCredits( $path, $info );
  203. if ( isset( $info['callback'] ) ) {
  204. $this->callbacks[$name] = $info['callback'];
  205. }
  206. // config should be after all core globals are extracted,
  207. // so duplicate setting detection will work fully
  208. if ( $version === 2 ) {
  209. $this->extractConfig2( $info, $dir );
  210. } else {
  211. // $version === 1
  212. $this->extractConfig1( $info );
  213. }
  214. if ( $version === 2 ) {
  215. $this->extractAttributes( $path, $info );
  216. }
  217. foreach ( $info as $key => $val ) {
  218. // If it's a global setting,
  219. if ( in_array( $key, self::$globalSettings ) ) {
  220. $this->storeToArray( $path, "wg$key", $val, $this->globals );
  221. continue;
  222. }
  223. // Ignore anything that starts with a @
  224. if ( $key[0] === '@' ) {
  225. continue;
  226. }
  227. if ( $version === 2 ) {
  228. // Only whitelisted attributes are set
  229. if ( in_array( $key, self::$coreAttributes ) ) {
  230. $this->storeToArray( $path, $key, $val, $this->attributes );
  231. }
  232. } else {
  233. // version === 1
  234. if ( !in_array( $key, self::$notAttributes )
  235. && !in_array( $key, self::$creditsAttributes )
  236. ) {
  237. // If it's not blacklisted, it's an attribute
  238. $this->storeToArray( $path, $key, $val, $this->attributes );
  239. }
  240. }
  241. }
  242. }
  243. /**
  244. * @param string $path
  245. * @param array $info
  246. */
  247. protected function extractAttributes( $path, array $info ) {
  248. if ( isset( $info['attributes'] ) ) {
  249. foreach ( $info['attributes'] as $extName => $value ) {
  250. $this->storeToArray( $path, $extName, $value, $this->extAttributes );
  251. }
  252. }
  253. }
  254. public function getExtractedInfo() {
  255. // Make sure the merge strategies are set
  256. foreach ( $this->globals as $key => $val ) {
  257. if ( isset( self::$mergeStrategies[$key] ) ) {
  258. $this->globals[$key][ExtensionRegistry::MERGE_STRATEGY] = self::$mergeStrategies[$key];
  259. }
  260. }
  261. // Merge $this->extAttributes into $this->attributes depending on what is loaded
  262. foreach ( $this->extAttributes as $extName => $value ) {
  263. // Only set the attribute if $extName is loaded (and hence present in credits)
  264. if ( isset( $this->credits[$extName] ) ) {
  265. foreach ( $value as $attrName => $attrValue ) {
  266. $this->storeToArray(
  267. '', // Don't provide a path since it's impossible to generate an error here
  268. $extName . $attrName,
  269. $attrValue,
  270. $this->attributes
  271. );
  272. }
  273. unset( $this->extAttributes[$extName] );
  274. }
  275. }
  276. return [
  277. 'globals' => $this->globals,
  278. 'config' => $this->config,
  279. 'defines' => $this->defines,
  280. 'callbacks' => $this->callbacks,
  281. 'credits' => $this->credits,
  282. 'attributes' => $this->attributes,
  283. ];
  284. }
  285. public function getRequirements( array $info, $includeDev ) {
  286. // Quick shortcuts
  287. if ( !$includeDev || !isset( $info['dev-requires'] ) ) {
  288. return $info['requires'] ?? [];
  289. }
  290. if ( !isset( $info['requires'] ) ) {
  291. return $info['dev-requires'] ?? [];
  292. }
  293. // OK, we actually have to merge everything
  294. $merged = [];
  295. // Helper that combines version requirements by
  296. // picking the non-null if one is, or combines
  297. // the two. Note that it is not possible for
  298. // both inputs to be null.
  299. $pick = function ( $a, $b ) {
  300. if ( $a === null ) {
  301. return $b;
  302. } elseif ( $b === null ) {
  303. return $a;
  304. } else {
  305. return "$a $b";
  306. }
  307. };
  308. $req = $info['requires'];
  309. $dev = $info['dev-requires'];
  310. if ( isset( $req['MediaWiki'] ) || isset( $dev['MediaWiki'] ) ) {
  311. $merged['MediaWiki'] = $pick(
  312. $req['MediaWiki'] ?? null,
  313. $dev['MediaWiki'] ?? null
  314. );
  315. }
  316. $platform = array_merge(
  317. array_keys( $req['platform'] ?? [] ),
  318. array_keys( $dev['platform'] ?? [] )
  319. );
  320. if ( $platform ) {
  321. foreach ( $platform as $pkey ) {
  322. if ( $pkey === 'php' ) {
  323. $value = $pick(
  324. $req['platform']['php'] ?? null,
  325. $dev['platform']['php'] ?? null
  326. );
  327. } else {
  328. // Prefer dev value, but these should be constant
  329. // anyways (ext-* and ability-*)
  330. $value = $dev['platform'][$pkey] ?? $req['platform'][$pkey];
  331. }
  332. $merged['platform'][$pkey] = $value;
  333. }
  334. }
  335. foreach ( [ 'extensions', 'skins' ] as $thing ) {
  336. $things = array_merge(
  337. array_keys( $req[$thing] ?? [] ),
  338. array_keys( $dev[$thing] ?? [] )
  339. );
  340. foreach ( $things as $name ) {
  341. $merged[$thing][$name] = $pick(
  342. $req[$thing][$name] ?? null,
  343. $dev[$thing][$name] ?? null
  344. );
  345. }
  346. }
  347. return $merged;
  348. }
  349. protected function extractHooks( array $info ) {
  350. if ( isset( $info['Hooks'] ) ) {
  351. foreach ( $info['Hooks'] as $name => $value ) {
  352. if ( is_array( $value ) ) {
  353. foreach ( $value as $callback ) {
  354. $this->globals['wgHooks'][$name][] = $callback;
  355. }
  356. } else {
  357. $this->globals['wgHooks'][$name][] = $value;
  358. }
  359. }
  360. }
  361. }
  362. /**
  363. * Register namespaces with the appropriate global settings
  364. *
  365. * @param array $info
  366. */
  367. protected function extractNamespaces( array $info ) {
  368. if ( isset( $info['namespaces'] ) ) {
  369. foreach ( $info['namespaces'] as $ns ) {
  370. if ( defined( $ns['constant'] ) ) {
  371. // If the namespace constant is already defined, use it.
  372. // This allows namespace IDs to be overwritten locally.
  373. $id = constant( $ns['constant'] );
  374. } else {
  375. $id = $ns['id'];
  376. $this->defines[ $ns['constant'] ] = $id;
  377. }
  378. if ( !( isset( $ns['conditional'] ) && $ns['conditional'] ) ) {
  379. // If it is not conditional, register it
  380. $this->attributes['ExtensionNamespaces'][$id] = $ns['name'];
  381. }
  382. if ( isset( $ns['gender'] ) ) {
  383. $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender'];
  384. }
  385. if ( isset( $ns['subpages'] ) && $ns['subpages'] ) {
  386. $this->globals['wgNamespacesWithSubpages'][$id] = true;
  387. }
  388. if ( isset( $ns['content'] ) && $ns['content'] ) {
  389. $this->globals['wgContentNamespaces'][] = $id;
  390. }
  391. if ( isset( $ns['defaultcontentmodel'] ) ) {
  392. $this->globals['wgNamespaceContentModels'][$id] = $ns['defaultcontentmodel'];
  393. }
  394. if ( isset( $ns['protection'] ) ) {
  395. $this->globals['wgNamespaceProtection'][$id] = $ns['protection'];
  396. }
  397. if ( isset( $ns['capitallinkoverride'] ) ) {
  398. $this->globals['wgCapitalLinkOverrides'][$id] = $ns['capitallinkoverride'];
  399. }
  400. }
  401. }
  402. }
  403. protected function extractResourceLoaderModules( $dir, array $info ) {
  404. $defaultPaths = $info['ResourceFileModulePaths'] ?? false;
  405. if ( isset( $defaultPaths['localBasePath'] ) ) {
  406. if ( $defaultPaths['localBasePath'] === '' ) {
  407. // Avoid double slashes (e.g. /extensions/Example//path)
  408. $defaultPaths['localBasePath'] = $dir;
  409. } else {
  410. $defaultPaths['localBasePath'] = "$dir/{$defaultPaths['localBasePath']}";
  411. }
  412. }
  413. foreach ( [ 'ResourceModules', 'ResourceModuleSkinStyles', 'OOUIThemePaths' ] as $setting ) {
  414. if ( isset( $info[$setting] ) ) {
  415. foreach ( $info[$setting] as $name => $data ) {
  416. if ( isset( $data['localBasePath'] ) ) {
  417. if ( $data['localBasePath'] === '' ) {
  418. // Avoid double slashes (e.g. /extensions/Example//path)
  419. $data['localBasePath'] = $dir;
  420. } else {
  421. $data['localBasePath'] = "$dir/{$data['localBasePath']}";
  422. }
  423. }
  424. if ( $defaultPaths ) {
  425. $data += $defaultPaths;
  426. }
  427. if ( $setting === 'OOUIThemePaths' ) {
  428. $this->attributes[$setting][$name] = $data;
  429. } else {
  430. $this->globals["wg$setting"][$name] = $data;
  431. }
  432. }
  433. }
  434. }
  435. if ( isset( $info['QUnitTestModule'] ) ) {
  436. $data = $info['QUnitTestModule'];
  437. if ( isset( $data['localBasePath'] ) ) {
  438. if ( $data['localBasePath'] === '' ) {
  439. // Avoid double slashes (e.g. /extensions/Example//path)
  440. $data['localBasePath'] = $dir;
  441. } else {
  442. $data['localBasePath'] = "$dir/{$data['localBasePath']}";
  443. }
  444. }
  445. $this->attributes['QUnitTestModules']["test.{$info['name']}"] = $data;
  446. }
  447. }
  448. protected function extractExtensionMessagesFiles( $dir, array $info ) {
  449. if ( isset( $info['ExtensionMessagesFiles'] ) ) {
  450. foreach ( $info['ExtensionMessagesFiles'] as &$file ) {
  451. $file = "$dir/$file";
  452. }
  453. $this->globals["wgExtensionMessagesFiles"] += $info['ExtensionMessagesFiles'];
  454. }
  455. }
  456. /**
  457. * Set message-related settings, which need to be expanded to use
  458. * absolute paths
  459. *
  460. * @param string $dir
  461. * @param array $info
  462. */
  463. protected function extractMessagesDirs( $dir, array $info ) {
  464. if ( isset( $info['MessagesDirs'] ) ) {
  465. foreach ( $info['MessagesDirs'] as $name => $files ) {
  466. foreach ( (array)$files as $file ) {
  467. $this->globals["wgMessagesDirs"][$name][] = "$dir/$file";
  468. }
  469. }
  470. }
  471. }
  472. /**
  473. * @param string $path
  474. * @param array $info
  475. * @return string Name of thing
  476. * @throws Exception
  477. */
  478. protected function extractCredits( $path, array $info ) {
  479. $credits = [
  480. 'path' => $path,
  481. 'type' => $info['type'] ?? 'other',
  482. ];
  483. foreach ( self::$creditsAttributes as $attr ) {
  484. if ( isset( $info[$attr] ) ) {
  485. $credits[$attr] = $info[$attr];
  486. }
  487. }
  488. $name = $credits['name'];
  489. // If someone is loading the same thing twice, throw
  490. // a nice error (T121493)
  491. if ( isset( $this->credits[$name] ) ) {
  492. $firstPath = $this->credits[$name]['path'];
  493. $secondPath = $credits['path'];
  494. throw new Exception( "It was attempted to load $name twice, from $firstPath and $secondPath." );
  495. }
  496. $this->credits[$name] = $credits;
  497. $this->globals['wgExtensionCredits'][$credits['type']][] = $credits;
  498. return $name;
  499. }
  500. /**
  501. * Set configuration settings for manifest_version == 1
  502. * @todo In the future, this should be done via Config interfaces
  503. *
  504. * @param array $info
  505. */
  506. protected function extractConfig1( array $info ) {
  507. if ( isset( $info['config'] ) ) {
  508. if ( isset( $info['config']['_prefix'] ) ) {
  509. $prefix = $info['config']['_prefix'];
  510. unset( $info['config']['_prefix'] );
  511. } else {
  512. $prefix = 'wg';
  513. }
  514. foreach ( $info['config'] as $key => $val ) {
  515. if ( $key[0] !== '@' ) {
  516. $this->addConfigGlobal( "$prefix$key", $val, $info['name'] );
  517. }
  518. }
  519. }
  520. }
  521. /**
  522. * Set configuration settings for manifest_version == 2
  523. * @todo In the future, this should be done via Config interfaces
  524. *
  525. * @param array $info
  526. * @param string $dir
  527. */
  528. protected function extractConfig2( array $info, $dir ) {
  529. $prefix = $info['config_prefix'] ?? 'wg';
  530. if ( isset( $info['config'] ) ) {
  531. foreach ( $info['config'] as $key => $data ) {
  532. $value = $data['value'];
  533. if ( isset( $data['merge_strategy'] ) ) {
  534. $value[ExtensionRegistry::MERGE_STRATEGY] = $data['merge_strategy'];
  535. }
  536. if ( isset( $data['path'] ) && $data['path'] ) {
  537. $value = "$dir/$value";
  538. }
  539. $this->addConfigGlobal( "$prefix$key", $value, $info['name'] );
  540. $data['providedby'] = $info['name'];
  541. if ( isset( $info['ConfigRegistry'][0] ) ) {
  542. $data['configregistry'] = array_keys( $info['ConfigRegistry'] )[0];
  543. }
  544. $this->config[$key] = $data;
  545. }
  546. }
  547. }
  548. /**
  549. * Helper function to set a value to a specific global, if it isn't set already.
  550. *
  551. * @param string $key The config key with the prefix and anything
  552. * @param mixed $value The value of the config
  553. * @param string $extName Name of the extension
  554. */
  555. private function addConfigGlobal( $key, $value, $extName ) {
  556. if ( array_key_exists( $key, $this->globals ) ) {
  557. throw new RuntimeException(
  558. "The configuration setting '$key' was already set by MediaWiki core or"
  559. . " another extension, and cannot be set again by $extName." );
  560. }
  561. $this->globals[$key] = $value;
  562. }
  563. protected function extractPathBasedGlobal( $global, $dir, $paths ) {
  564. foreach ( $paths as $path ) {
  565. $this->globals[$global][] = "$dir/$path";
  566. }
  567. }
  568. /**
  569. * @param string $path
  570. * @param string $name
  571. * @param array $value
  572. * @param array &$array
  573. * @throws InvalidArgumentException
  574. */
  575. protected function storeToArray( $path, $name, $value, &$array ) {
  576. if ( !is_array( $value ) ) {
  577. throw new InvalidArgumentException( "The value for '$name' should be an array (from $path)" );
  578. }
  579. if ( isset( $array[$name] ) ) {
  580. $array[$name] = array_merge_recursive( $array[$name], $value );
  581. } else {
  582. $array[$name] = $value;
  583. }
  584. }
  585. public function getExtraAutoloaderPaths( $dir, array $info ) {
  586. $paths = [];
  587. if ( isset( $info['load_composer_autoloader'] ) && $info['load_composer_autoloader'] === true ) {
  588. $paths[] = "$dir/vendor/autoload.php";
  589. }
  590. return $paths;
  591. }
  592. }