Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.23% covered (success)
90.23%
157 / 174
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StartUpModule
90.23% covered (success)
90.23%
157 / 174
72.73% covered (warning)
72.73%
8 / 11
52.33
0.00% covered (danger)
0.00%
0 / 1
 getImplicitDependencies
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 compileUnresolvedDependencies
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getModuleRegistrations
87.30% covered (warning)
87.30%
55 / 63
0.00% covered (danger)
0.00%
0 / 1
17.59
 getGroupId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getBaseModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStoreKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxQueryLength
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getStoreVary
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getScript
88.06% covered (warning)
88.06%
59 / 67
0.00% covered (danger)
0.00%
0 / 1
13.29
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enableModuleContentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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 */
22namespace MediaWiki\ResourceLoader;
23
24use Exception;
25use MediaWiki\MainConfigNames;
26use Wikimedia\RequestTimeout\TimeoutException;
27
28/**
29 * Module for ResourceLoader initialization.
30 *
31 * See also <https://www.mediawiki.org/wiki/ResourceLoader/Features#Startup_Module>
32 *
33 * The startup module, as being called only from ClientHtml, has
34 * the ability to vary based extra query parameters, in addition to those
35 * from Context:
36 *
37 * - safemode: Only register modules that have ORIGIN_CORE as their origin.
38 *   This disables ORIGIN_USER modules and mw.loader.store. (T185303, T145498)
39 *   See also: OutputPage::disallowUserJs()
40 *
41 * @ingroup ResourceLoader
42 * @internal
43 */
44class StartUpModule extends Module {
45
46    /**
47     * Cache version for client-side ResourceLoader module storage.
48     * Like ResourceLoaderStorageVersion but not configurable.
49     */
50    private const STORAGE_VERSION = '2';
51
52    private $groupIds = [
53        // These reserved numbers MUST start at 0 and not skip any. These are preset
54        // for forward compatibility so that they can be safely referenced by mediawiki.js,
55        // even when the code is cached and the order of registrations (and implicit
56        // group ids) changes between versions of the software.
57        self::GROUP_USER => 0,
58        self::GROUP_PRIVATE => 1,
59    ];
60
61    /**
62     * Recursively get all explicit and implicit dependencies for to the given module.
63     *
64     * @param array $registryData
65     * @param string $moduleName
66     * @param string[] $handled Internal parameter for recursion. (Optional)
67     * @return array
68     * @throws CircularDependencyError
69     */
70    protected static function getImplicitDependencies(
71        array $registryData,
72        string $moduleName,
73        array $handled = []
74    ): array {
75        static $dependencyCache = [];
76
77        // No modules will be added or changed server-side after this point,
78        // so we can safely cache parts of the tree for re-use.
79        if ( !isset( $dependencyCache[$moduleName] ) ) {
80            if ( !isset( $registryData[$moduleName] ) ) {
81                // Unknown module names are allowed here, this is only an optimisation.
82                // Checks for illegal and unknown dependencies happen as PHPUnit structure tests,
83                // and also client-side at run-time.
84                $dependencyCache[$moduleName] = [];
85                return [];
86            }
87
88            $data = $registryData[$moduleName];
89            $flat = $data['dependencies'];
90
91            // Prevent recursion
92            $handled[] = $moduleName;
93            foreach ( $data['dependencies'] as $dependency ) {
94                if ( in_array( $dependency, $handled, true ) ) {
95                    // If we encounter a circular dependency, then stop the optimiser and leave the
96                    // original dependencies array unmodified. Circular dependencies are not
97                    // supported in ResourceLoader. Awareness of them exists here so that we can
98                    // optimise the registry when it isn't broken, and otherwise transport the
99                    // registry unchanged. The client will handle this further.
100                    throw new CircularDependencyError();
101                }
102                // Recursively add the dependencies of the dependencies
103                $flat = array_merge(
104                    $flat,
105                    self::getImplicitDependencies( $registryData, $dependency, $handled )
106                );
107            }
108
109            $dependencyCache[$moduleName] = $flat;
110        }
111
112        return $dependencyCache[$moduleName];
113    }
114
115    /**
116     * Optimize the dependency tree in $this->modules.
117     *
118     * The optimization basically works like this:
119     *     Given we have module A with the dependencies B and C
120     *         and module B with the dependency C.
121     *     Now we don't have to tell the client to explicitly fetch module
122     *         C as that's already included in module B.
123     *
124     * This way we can reasonably reduce the amount of module registration
125     * data send to the client.
126     *
127     * @param array[] &$registryData Modules keyed by name with properties:
128     *  - string 'version'
129     *  - array 'dependencies'
130     *  - string|null 'group'
131     *  - string 'source'
132     * @phan-param array<string,array{version:string,dependencies:array,group:?string,source:string}> &$registryData
133     */
134    public static function compileUnresolvedDependencies( array &$registryData ): void {
135        foreach ( $registryData as &$data ) {
136            $dependencies = $data['dependencies'];
137            try {
138                foreach ( $data['dependencies'] as $dependency ) {
139                    $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency );
140                    $dependencies = array_diff( $dependencies, $implicitDependencies );
141                }
142            } catch ( CircularDependencyError $err ) {
143                // Leave unchanged
144                $dependencies = $data['dependencies'];
145            }
146
147            // Rebuild keys
148            $data['dependencies'] = array_values( $dependencies );
149        }
150    }
151
152    /**
153     * Get registration code for all modules.
154     *
155     * @param Context $context
156     * @return string JavaScript code for registering all modules with the client loader
157     */
158    public function getModuleRegistrations( Context $context ): string {
159        $resourceLoader = $context->getResourceLoader();
160        // Future developers: Use WebRequest::getRawVal() instead getVal().
161        // The getVal() method performs slow Language+UTF logic. (f303bb9360)
162        $safemode = $context->getRequest()->getRawVal( 'safemode' ) === '1';
163        $skin = $context->getSkin();
164
165        $moduleNames = $resourceLoader->getModuleNames();
166
167        // Preload with a batch so that the below calls to getVersionHash() for each module
168        // don't require on-demand loading of more information.
169        try {
170            $resourceLoader->preloadModuleInfo( $moduleNames, $context );
171        } catch ( TimeoutException $e ) {
172            throw $e;
173        } catch ( Exception $e ) {
174            // Don't fail the request (T152266)
175            // Also print the error in the main output
176            $resourceLoader->outputErrorAndLog( $e,
177                'Preloading module info from startup failed: {exception}',
178                [ 'exception' => $e ]
179            );
180        }
181
182        // Get registry data
183        $states = [];
184        $registryData = [];
185        foreach ( $moduleNames as $name ) {
186            $module = $resourceLoader->getModule( $name );
187            $moduleSkins = $module->getSkins();
188            if (
189                ( $safemode && $module->getOrigin() > Module::ORIGIN_CORE_INDIVIDUAL )
190                || ( $moduleSkins !== null && !in_array( $skin, $moduleSkins ) )
191            ) {
192                continue;
193            }
194
195            if ( $module instanceof StartUpModule ) {
196                // Don't register 'startup' to the client because loading it lazily or depending
197                // on it doesn't make sense, because the startup module *is* the client.
198                // Registering would be a waste of bandwidth and memory and risks somehow causing
199                // it to load a second time.
200
201                // ATTENTION: Because of the line below, this is not going to cause infinite recursion.
202                // Think carefully before making changes to this code!
203                // The below code is going to call Module::getVersionHash() for every module.
204                // For StartUpModule (this module) the hash is computed based on the manifest content,
205                // which is the very thing we are computing right here. As such, this must skip iterating
206                // over 'startup' itself.
207                continue;
208            }
209
210            // Optimization: Exclude modules in the `noscript` group. These are only ever used
211            // directly by HTML without use of JavaScript (T291735).
212            if ( $module->getGroup() === self::GROUP_NOSCRIPT ) {
213                continue;
214            }
215
216            try {
217                // The version should be formatted by ResourceLoader::makeHash and be of
218                // length ResourceLoader::HASH_LENGTH (or empty string).
219                // The getVersionHash method is final and is covered by tests, as is makeHash().
220                $versionHash = $module->getVersionHash( $context );
221            } catch ( TimeoutException $e ) {
222                throw $e;
223            } catch ( Exception $e ) {
224                // Don't fail the request (T152266)
225                // Also print the error in the main output
226                $resourceLoader->outputErrorAndLog( $e,
227                    'Calculating version for "{module}" failed: {exception}',
228                    [
229                        'module' => $name,
230                        'exception' => $e,
231                    ]
232                );
233                $versionHash = '';
234                $states[$name] = 'error';
235            }
236
237            $skipFunction = $module->getSkipFunction();
238            if ( $skipFunction !== null && !$context->getDebug() ) {
239                $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction );
240            }
241
242            $registryData[$name] = [
243                'version' => $versionHash,
244                'dependencies' => $module->getDependencies( $context ),
245                'group' => $this->getGroupId( $module->getGroup() ),
246                'source' => $module->getSource(),
247                'skip' => $skipFunction,
248            ];
249        }
250
251        self::compileUnresolvedDependencies( $registryData );
252
253        // Register sources
254        $out = ResourceLoader::makeLoaderSourcesScript( $context, $resourceLoader->getSources() );
255
256        // Figure out the different call signatures for mw.loader.register
257        $registrations = [];
258        foreach ( $registryData as $name => $data ) {
259            // Call mw.loader.register(name, version, dependencies, group, source, skip)
260            $registrations[] = [
261                $name,
262                $data['version'],
263                $data['dependencies'],
264                $data['group'],
265                // Swap default (local) for null
266                $data['source'] === 'local' ? null : $data['source'],
267                $data['skip']
268            ];
269        }
270
271        // Register modules
272        $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $context, $registrations );
273
274        if ( $states ) {
275            $out .= "\n" . ResourceLoader::makeLoaderStateScript( $context, $states );
276        }
277
278        return $out;
279    }
280
281    private function getGroupId( $groupName ): ?int {
282        if ( $groupName === null ) {
283            return null;
284        }
285
286        if ( !array_key_exists( $groupName, $this->groupIds ) ) {
287            $this->groupIds[$groupName] = count( $this->groupIds );
288        }
289
290        return $this->groupIds[$groupName];
291    }
292
293    /**
294     * Base modules implicitly available to all modules.
295     *
296     * @return array
297     */
298    private function getBaseModules(): array {
299        return [ 'jquery', 'mediawiki.base' ];
300    }
301
302    /**
303     * Get the localStorage key for the entire module store. The key references
304     * $wgDBname to prevent clashes between wikis under the same web domain.
305     *
306     * @return string localStorage item key for JavaScript
307     */
308    private function getStoreKey(): string {
309        return 'MediaWikiModuleStore:' . $this->getConfig()->get( MainConfigNames::DBname );
310    }
311
312    /**
313     * @see $wgResourceLoaderMaxQueryLength
314     * @return int
315     */
316    private function getMaxQueryLength(): int {
317        $len = $this->getConfig()->get( MainConfigNames::ResourceLoaderMaxQueryLength );
318        // - Ignore -1, which in MW 1.34 and earlier was used to mean "unlimited".
319        // - Ignore invalid values, e.g. non-int or other negative values.
320        if ( $len === false || $len < 0 ) {
321            // Default
322            $len = 2000;
323        }
324        return $len;
325    }
326
327    /**
328     * Get the key on which the JavaScript module cache (mw.loader.store) will vary.
329     *
330     * @param Context $context
331     * @return string String of concatenated vary conditions
332     */
333    private function getStoreVary( Context $context ): string {
334        return implode( ':', [
335            $context->getSkin(),
336            self::STORAGE_VERSION,
337            $this->getConfig()->get( MainConfigNames::ResourceLoaderStorageVersion ),
338            $context->getLanguage(),
339        ] );
340    }
341
342    /**
343     * @param Context $context
344     * @return string|array JavaScript code
345     */
346    public function getScript( Context $context ) {
347        global $IP;
348        $conf = $this->getConfig();
349
350        if ( $context->getOnly() !== 'scripts' ) {
351            return '/* Requires only=scripts */';
352        }
353
354        $enableJsProfiler = $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler );
355
356        $startupCode = file_get_contents( "$IP/resources/src/startup/startup.js" );
357
358        $mwLoaderCode = file_get_contents( "$IP/resources/src/startup/mediawiki.js" ) .
359            file_get_contents( "$IP/resources/src/startup/mediawiki.loader.js" ) .
360            file_get_contents( "$IP/resources/src/startup/mediawiki.requestIdleCallback.js" );
361        if ( $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ) ) {
362            $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" );
363        }
364
365        // Perform replacements for mediawiki.js
366        $mwLoaderPairs = [
367            // This should always be an object, even if the base vars are empty
368            // (such as when using the default lang/skin).
369            '$VARS.reqBase' => $context->encodeJson( (object)$context->getReqBase() ),
370            '$VARS.baseModules' => $context->encodeJson( $this->getBaseModules() ),
371            '$VARS.maxQueryLength' => $context->encodeJson(
372                // In debug mode (except legacy debug mode), let the client fetch each module in
373                // its own dedicated request (T85805).
374                // This is effectively the equivalent of ClientHtml::makeLoad,
375                // which does this for stylesheets.
376                ( !$context->getDebug() || $context->getDebug() === $context::DEBUG_LEGACY ) ?
377                    $this->getMaxQueryLength() :
378                    0
379            ),
380            '$VARS.storeEnabled' => $context->encodeJson(
381                $conf->get( MainConfigNames::ResourceLoaderStorageEnabled )
382                    && !$context->getDebug()
383                    && $context->getRequest()->getRawVal( 'safemode' ) !== '1'
384            ),
385            '$VARS.storeKey' => $context->encodeJson( $this->getStoreKey() ),
386            '$VARS.storeVary' => $context->encodeJson( $this->getStoreVary( $context ) ),
387            '$VARS.groupUser' => $context->encodeJson( $this->getGroupId( self::GROUP_USER ) ),
388            '$VARS.groupPrivate' => $context->encodeJson( $this->getGroupId( self::GROUP_PRIVATE ) ),
389            '$VARS.sourceMapLinks' => $context->encodeJson(
390                $conf->get( MainConfigNames::ResourceLoaderEnableSourceMapLinks )
391            ),
392
393            // When profiling is enabled, insert the calls.
394            // When disabled (the default), insert nothing.
395            '$CODE.profileExecuteStart();' => $enableJsProfiler
396                ? 'mw.loader.profiler.onExecuteStart( module );'
397                : '',
398            '$CODE.profileExecuteEnd();' => $enableJsProfiler
399                ? 'mw.loader.profiler.onExecuteEnd( module );'
400                : '',
401            '$CODE.profileScriptStart();' => $enableJsProfiler
402                ? 'mw.loader.profiler.onScriptStart( module );'
403                : '',
404            '$CODE.profileScriptEnd();' => $enableJsProfiler
405                ? 'mw.loader.profiler.onScriptEnd( module );'
406                : '',
407
408            // Debug stubs
409            '$CODE.consoleLog();' => $context->getDebug()
410                ? 'console.log.apply( console, arguments );'
411                : '',
412
413            // As a paranoia measure, create a window.QUnit placeholder that shadows any
414            // DOM global (e.g. for <h2 id="QUnit">), to avoid test code in prod (T356768).
415            '$CODE.undefineQUnit();' => !$conf->get( MainConfigNames::EnableJavaScriptTest )
416                ? 'window.QUnit = undefined;'
417                : '',
418        ];
419        $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs );
420
421        // Perform string replacements for startup.js
422        $pairs = [
423            // Raw JavaScript code (not JSON)
424            '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ),
425            '$CODE.defineLoader();' => $mwLoaderCode,
426        ];
427        $startupCode = strtr( $startupCode, $pairs );
428
429        return [
430            'plainScripts' => [
431                [
432                    'virtualFilePath' => new FilePath(
433                        'resources/src/startup/startup.js',
434                        MW_INSTALL_PATH,
435                        $conf->get( MainConfigNames::ResourceBasePath )
436                    ),
437                    'content' => $startupCode,
438                ],
439            ],
440        ];
441    }
442
443    /**
444     * @return bool
445     */
446    public function supportsURLLoading(): bool {
447        return false;
448    }
449
450    /**
451     * @return bool
452     */
453    public function enableModuleContentVersion(): bool {
454        // Enabling this means that ResourceLoader::getVersionHash will simply call getScript()
455        // and hash it to determine the version (as used by E-Tag HTTP response header).
456        return true;
457    }
458}