ResourceLoaderStartUpModule.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. <?php
  2. /**
  3. * This program is free software; you can redistribute it and/or modify
  4. * it under the terms of the GNU General Public License as published by
  5. * the Free Software Foundation; either version 2 of the License, or
  6. * (at your option) any later version.
  7. *
  8. * This program is distributed in the hope that it will be useful,
  9. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. * GNU General Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along
  14. * with this program; if not, write to the Free Software Foundation, Inc.,
  15. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. * http://www.gnu.org/copyleft/gpl.html
  17. *
  18. * @file
  19. * @author Trevor Parscal
  20. * @author Roan Kattouw
  21. */
  22. use MediaWiki\MediaWikiServices;
  23. /**
  24. * Module for ResourceLoader initialization.
  25. *
  26. * See also <https://www.mediawiki.org/wiki/ResourceLoader/Features#Startup_Module>
  27. *
  28. * The startup module, as being called only from ResourceLoaderClientHtml, has
  29. * the ability to vary based extra query parameters, in addition to those
  30. * from ResourceLoaderContext:
  31. *
  32. * - target: Only register modules in the client intended for this target.
  33. * Default: "desktop".
  34. * See also: OutputPage::setTarget(), ResourceLoaderModule::getTargets().
  35. *
  36. * - safemode: Only register modules that have ORIGIN_CORE as their origin.
  37. * This effectively disables ORIGIN_USER modules. (T185303)
  38. * See also: OutputPage::disallowUserJs()
  39. *
  40. * @ingroup ResourceLoader
  41. * @internal
  42. */
  43. class ResourceLoaderStartUpModule extends ResourceLoaderModule {
  44. protected $targets = [ 'desktop', 'mobile' ];
  45. private $groupIds = [
  46. // These reserved numbers MUST start at 0 and not skip any. These are preset
  47. // for forward compatiblity so that they can be safely referenced by mediawiki.js,
  48. // even when the code is cached and the order of registrations (and implicit
  49. // group ids) changes between versions of the software.
  50. 'user' => 0,
  51. 'private' => 1,
  52. ];
  53. /**
  54. * @param ResourceLoaderContext $context
  55. * @return array
  56. */
  57. private function getConfigSettings( ResourceLoaderContext $context ) {
  58. $conf = $this->getConfig();
  59. /**
  60. * Namespace related preparation
  61. * - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
  62. * - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
  63. */
  64. $contLang = MediaWikiServices::getInstance()->getContentLanguage();
  65. $namespaceIds = $contLang->getNamespaceIds();
  66. $caseSensitiveNamespaces = [];
  67. $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
  68. foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
  69. $namespaceIds[$contLang->lc( $name )] = $index;
  70. if ( !$nsInfo->isCapitalized( $index ) ) {
  71. $caseSensitiveNamespaces[] = $index;
  72. }
  73. }
  74. $illegalFileChars = $conf->get( 'IllegalFileChars' );
  75. // Build list of variables
  76. $skin = $context->getSkin();
  77. $vars = [
  78. 'debug' => $context->getDebug(),
  79. 'skin' => $skin,
  80. 'stylepath' => $conf->get( 'StylePath' ),
  81. 'wgUrlProtocols' => wfUrlProtocols(),
  82. 'wgArticlePath' => $conf->get( 'ArticlePath' ),
  83. 'wgScriptPath' => $conf->get( 'ScriptPath' ),
  84. 'wgScript' => $conf->get( 'Script' ),
  85. 'wgSearchType' => $conf->get( 'SearchType' ),
  86. 'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ),
  87. // Force object to avoid "empty" associative array from
  88. // becoming [] instead of {} in JS (T36604)
  89. 'wgActionPaths' => (object)$conf->get( 'ActionPaths' ),
  90. 'wgServer' => $conf->get( 'Server' ),
  91. 'wgServerName' => $conf->get( 'ServerName' ),
  92. 'wgUserLanguage' => $context->getLanguage(),
  93. 'wgContentLanguage' => $contLang->getCode(),
  94. 'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ),
  95. 'wgVersion' => $conf->get( 'Version' ),
  96. 'wgEnableAPI' => true, // Deprecated since MW 1.32
  97. 'wgEnableWriteAPI' => true, // Deprecated since MW 1.32
  98. 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
  99. 'wgNamespaceIds' => $namespaceIds,
  100. 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
  101. 'wgSiteName' => $conf->get( 'Sitename' ),
  102. 'wgDBname' => $conf->get( 'DBname' ),
  103. 'wgWikiID' => WikiMap::getWikiIdFromDbDomain( WikiMap::getCurrentWikiDbDomain() ),
  104. 'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ),
  105. 'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ),
  106. // MediaWiki sets cookies to have this prefix by default
  107. 'wgCookiePrefix' => $conf->get( 'CookiePrefix' ),
  108. 'wgCookieDomain' => $conf->get( 'CookieDomain' ),
  109. 'wgCookiePath' => $conf->get( 'CookiePath' ),
  110. 'wgCookieExpiration' => $conf->get( 'CookieExpiration' ),
  111. 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
  112. 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
  113. 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
  114. 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ),
  115. 'wgEnableUploads' => $conf->get( 'EnableUploads' ),
  116. 'wgCommentByteLimit' => null,
  117. 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
  118. ];
  119. Hooks::run( 'ResourceLoaderGetConfigVars', [ &$vars, $skin, $conf ] );
  120. return $vars;
  121. }
  122. /**
  123. * Recursively get all explicit and implicit dependencies for to the given module.
  124. *
  125. * @param array $registryData
  126. * @param string $moduleName
  127. * @param string[] $handled Internal parameter for recursion. (Optional)
  128. * @return array
  129. * @throws ResourceLoaderCircularDependencyError
  130. */
  131. protected static function getImplicitDependencies(
  132. array $registryData,
  133. $moduleName,
  134. array $handled = []
  135. ) {
  136. static $dependencyCache = [];
  137. // No modules will be added or changed server-side after this point,
  138. // so we can safely cache parts of the tree for re-use.
  139. if ( !isset( $dependencyCache[$moduleName] ) ) {
  140. if ( !isset( $registryData[$moduleName] ) ) {
  141. // Unknown module names are allowed here, this is only an optimisation.
  142. // Checks for illegal and unknown dependencies happen as PHPUnit structure tests,
  143. // and also client-side at run-time.
  144. $flat = [];
  145. } else {
  146. $data = $registryData[$moduleName];
  147. $flat = $data['dependencies'];
  148. // Prevent recursion
  149. $handled[] = $moduleName;
  150. foreach ( $data['dependencies'] as $dependency ) {
  151. if ( in_array( $dependency, $handled, true ) ) {
  152. // If we encounter a circular dependency, then stop the optimiser and leave the
  153. // original dependencies array unmodified. Circular dependencies are not
  154. // supported in ResourceLoader. Awareness of them exists here so that we can
  155. // optimise the registry when it isn't broken, and otherwise transport the
  156. // registry unchanged. The client will handle this further.
  157. throw new ResourceLoaderCircularDependencyError();
  158. } else {
  159. // Recursively add the dependencies of the dependencies
  160. $flat = array_merge(
  161. $flat,
  162. self::getImplicitDependencies( $registryData, $dependency, $handled )
  163. );
  164. }
  165. }
  166. }
  167. $dependencyCache[$moduleName] = $flat;
  168. }
  169. return $dependencyCache[$moduleName];
  170. }
  171. /**
  172. * Optimize the dependency tree in $this->modules.
  173. *
  174. * The optimization basically works like this:
  175. * Given we have module A with the dependencies B and C
  176. * and module B with the dependency C.
  177. * Now we don't have to tell the client to explicitly fetch module
  178. * C as that's already included in module B.
  179. *
  180. * This way we can reasonably reduce the amount of module registration
  181. * data send to the client.
  182. *
  183. * @param array &$registryData Modules keyed by name with properties:
  184. * - string 'version'
  185. * - array 'dependencies'
  186. * - string|null 'group'
  187. * - string 'source'
  188. */
  189. public static function compileUnresolvedDependencies( array &$registryData ) {
  190. foreach ( $registryData as $name => &$data ) {
  191. $dependencies = $data['dependencies'];
  192. try {
  193. foreach ( $data['dependencies'] as $dependency ) {
  194. $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency );
  195. $dependencies = array_diff( $dependencies, $implicitDependencies );
  196. }
  197. } catch ( ResourceLoaderCircularDependencyError $err ) {
  198. // Leave unchanged
  199. $dependencies = $data['dependencies'];
  200. }
  201. // Rebuild keys
  202. $data['dependencies'] = array_values( $dependencies );
  203. }
  204. }
  205. /**
  206. * Get registration code for all modules.
  207. *
  208. * @param ResourceLoaderContext $context
  209. * @return string JavaScript code for registering all modules with the client loader
  210. */
  211. public function getModuleRegistrations( ResourceLoaderContext $context ) {
  212. $resourceLoader = $context->getResourceLoader();
  213. // Future developers: Use WebRequest::getRawVal() instead getVal().
  214. // The getVal() method performs slow Language+UTF logic. (f303bb9360)
  215. $target = $context->getRequest()->getRawVal( 'target', 'desktop' );
  216. $safemode = $context->getRequest()->getRawVal( 'safemode' ) === '1';
  217. // Bypass target filter if this request is Special:JavaScriptTest.
  218. // To prevent misuse in production, this is only allowed if testing is enabled server-side.
  219. $byPassTargetFilter = $this->getConfig()->get( 'EnableJavaScriptTest' ) && $target === 'test';
  220. $out = '';
  221. $states = [];
  222. $registryData = [];
  223. $moduleNames = $resourceLoader->getModuleNames();
  224. // Preload with a batch so that the below calls to getVersionHash() for each module
  225. // don't require on-demand loading of more information.
  226. try {
  227. $resourceLoader->preloadModuleInfo( $moduleNames, $context );
  228. } catch ( Exception $e ) {
  229. // Don't fail the request (T152266)
  230. // Also print the error in the main output
  231. $resourceLoader->outputErrorAndLog( $e,
  232. 'Preloading module info from startup failed: {exception}',
  233. [ 'exception' => $e ]
  234. );
  235. }
  236. // Get registry data
  237. foreach ( $moduleNames as $name ) {
  238. $module = $resourceLoader->getModule( $name );
  239. $moduleTargets = $module->getTargets();
  240. if (
  241. ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) )
  242. || ( $safemode && $module->getOrigin() > ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL )
  243. ) {
  244. continue;
  245. }
  246. if ( $module instanceof ResourceLoaderStartUpModule ) {
  247. // Don't register 'startup' to the client because loading it lazily or depending
  248. // on it doesn't make sense, because the startup module *is* the client.
  249. // Registering would be a waste of bandwidth and memory and risks somehow causing
  250. // it to load a second time.
  251. // ATTENTION: Because of the line below, this is not going to cause infinite recursion.
  252. // Think carefully before making changes to this code!
  253. // The below code is going to call ResourceLoaderModule::getVersionHash() for every module.
  254. // For StartUpModule (this module) the hash is computed based on the manifest content,
  255. // which is the very thing we are computing right here. As such, this must skip iterating
  256. // over 'startup' itself.
  257. continue;
  258. }
  259. try {
  260. $versionHash = $module->getVersionHash( $context );
  261. } catch ( Exception $e ) {
  262. // Don't fail the request (T152266)
  263. // Also print the error in the main output
  264. $resourceLoader->outputErrorAndLog( $e,
  265. 'Calculating version for "{module}" failed: {exception}',
  266. [
  267. 'module' => $name,
  268. 'exception' => $e,
  269. ]
  270. );
  271. $versionHash = '';
  272. $states[$name] = 'error';
  273. }
  274. if ( $versionHash !== '' && strlen( $versionHash ) !== ResourceLoader::HASH_LENGTH ) {
  275. $e = new RuntimeException( "Badly formatted module version hash" );
  276. $resourceLoader->outputErrorAndLog( $e,
  277. "Module '{module}' produced an invalid version hash: '{version}'.",
  278. [
  279. 'module' => $name,
  280. 'version' => $versionHash,
  281. ]
  282. );
  283. // Module implementation either broken or deviated from ResourceLoader::makeHash
  284. // Asserted by tests/phpunit/structure/ResourcesTest.
  285. $versionHash = ResourceLoader::makeHash( $versionHash );
  286. }
  287. $skipFunction = $module->getSkipFunction();
  288. if ( $skipFunction !== null && !$context->getDebug() ) {
  289. $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction );
  290. }
  291. $registryData[$name] = [
  292. 'version' => $versionHash,
  293. 'dependencies' => $module->getDependencies( $context ),
  294. 'group' => $this->getGroupId( $module->getGroup() ),
  295. 'source' => $module->getSource(),
  296. 'skip' => $skipFunction,
  297. ];
  298. }
  299. self::compileUnresolvedDependencies( $registryData );
  300. // Register sources
  301. $out .= ResourceLoader::makeLoaderSourcesScript( $context, $resourceLoader->getSources() );
  302. // Figure out the different call signatures for mw.loader.register
  303. $registrations = [];
  304. foreach ( $registryData as $name => $data ) {
  305. // Call mw.loader.register(name, version, dependencies, group, source, skip)
  306. $registrations[] = [
  307. $name,
  308. $data['version'],
  309. $data['dependencies'],
  310. $data['group'],
  311. // Swap default (local) for null
  312. $data['source'] === 'local' ? null : $data['source'],
  313. $data['skip']
  314. ];
  315. }
  316. // Register modules
  317. $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $context, $registrations );
  318. if ( $states ) {
  319. $out .= "\n" . ResourceLoader::makeLoaderStateScript( $context, $states );
  320. }
  321. return $out;
  322. }
  323. private function getGroupId( $groupName ) {
  324. if ( $groupName === null ) {
  325. return null;
  326. }
  327. if ( !array_key_exists( $groupName, $this->groupIds ) ) {
  328. $this->groupIds[$groupName] = count( $this->groupIds );
  329. }
  330. return $this->groupIds[$groupName];
  331. }
  332. /**
  333. * Base modules implicitly available to all modules.
  334. *
  335. * @return array
  336. */
  337. private function getBaseModules() {
  338. $baseModules = [ 'jquery', 'mediawiki.base' ];
  339. return $baseModules;
  340. }
  341. /**
  342. * Get the localStorage key for the entire module store. The key references
  343. * $wgDBname to prevent clashes between wikis under the same web domain.
  344. *
  345. * @return string localStorage item key for JavaScript
  346. */
  347. private function getStoreKey() {
  348. return 'MediaWikiModuleStore:' . $this->getConfig()->get( 'DBname' );
  349. }
  350. /**
  351. * Get the key on which the JavaScript module cache (mw.loader.store) will vary.
  352. *
  353. * @param ResourceLoaderContext $context
  354. * @return string String of concatenated vary conditions
  355. */
  356. private function getStoreVary( ResourceLoaderContext $context ) {
  357. return implode( ':', [
  358. $context->getSkin(),
  359. $this->getConfig()->get( 'ResourceLoaderStorageVersion' ),
  360. $context->getLanguage(),
  361. ] );
  362. }
  363. /**
  364. * @param ResourceLoaderContext $context
  365. * @return string JavaScript code
  366. */
  367. public function getScript( ResourceLoaderContext $context ) {
  368. global $IP;
  369. $conf = $this->getConfig();
  370. if ( $context->getOnly() !== 'scripts' ) {
  371. return '/* Requires only=script */';
  372. }
  373. $startupCode = file_get_contents( "$IP/resources/src/startup/startup.js" );
  374. // The files read here MUST be kept in sync with maintenance/jsduck/eg-iframe.html,
  375. // and MUST be considered by 'fileHashes' in StartUpModule::getDefinitionSummary().
  376. $mwLoaderCode = file_get_contents( "$IP/resources/src/startup/mediawiki.js" ) .
  377. file_get_contents( "$IP/resources/src/startup/mediawiki.requestIdleCallback.js" );
  378. if ( $context->getDebug() ) {
  379. $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/mediawiki.log.js" );
  380. }
  381. if ( $conf->get( 'ResourceLoaderEnableJSProfiler' ) ) {
  382. $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" );
  383. }
  384. // Perform replacements for mediawiki.js
  385. $mwLoaderPairs = [
  386. '$VARS.reqBase' => $context->encodeJson( $context->getReqBase() ),
  387. '$VARS.baseModules' => $context->encodeJson( $this->getBaseModules() ),
  388. '$VARS.maxQueryLength' => $context->encodeJson(
  389. $conf->get( 'ResourceLoaderMaxQueryLength' )
  390. ),
  391. // The client-side module cache can be disabled by site configuration.
  392. // It is also always disabled in debug mode.
  393. '$VARS.storeEnabled' => $context->encodeJson(
  394. $conf->get( 'ResourceLoaderStorageEnabled' ) && !$context->getDebug()
  395. ),
  396. '$VARS.wgLegacyJavaScriptGlobals' => $context->encodeJson(
  397. $conf->get( 'LegacyJavaScriptGlobals' )
  398. ),
  399. '$VARS.storeKey' => $context->encodeJson( $this->getStoreKey() ),
  400. '$VARS.storeVary' => $context->encodeJson( $this->getStoreVary( $context ) ),
  401. '$VARS.groupUser' => $context->encodeJson( $this->getGroupId( 'user' ) ),
  402. '$VARS.groupPrivate' => $context->encodeJson( $this->getGroupId( 'private' ) ),
  403. ];
  404. $profilerStubs = [
  405. '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );',
  406. '$CODE.profileExecuteEnd();' => 'mw.loader.profiler.onExecuteEnd( module );',
  407. '$CODE.profileScriptStart();' => 'mw.loader.profiler.onScriptStart( module );',
  408. '$CODE.profileScriptEnd();' => 'mw.loader.profiler.onScriptEnd( module );',
  409. ];
  410. if ( $conf->get( 'ResourceLoaderEnableJSProfiler' ) ) {
  411. // When profiling is enabled, insert the calls.
  412. $mwLoaderPairs += $profilerStubs;
  413. } else {
  414. // When disabled (by default), insert nothing.
  415. $mwLoaderPairs += array_fill_keys( array_keys( $profilerStubs ), '' );
  416. }
  417. $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs );
  418. // Perform string replacements for startup.js
  419. $pairs = [
  420. '$VARS.configuration' => $context->encodeJson(
  421. $this->getConfigSettings( $context )
  422. ),
  423. // Raw JavaScript code (not JSON)
  424. '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ),
  425. '$CODE.defineLoader();' => $mwLoaderCode,
  426. ];
  427. $startupCode = strtr( $startupCode, $pairs );
  428. return $startupCode;
  429. }
  430. /**
  431. * @return bool
  432. */
  433. public function supportsURLLoading() {
  434. return false;
  435. }
  436. /**
  437. * @return bool
  438. */
  439. public function enableModuleContentVersion() {
  440. // Enabling this means that ResourceLoader::getVersionHash will simply call getScript()
  441. // and hash it to determine the version (as used by E-Tag HTTP response header).
  442. return true;
  443. }
  444. }