Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.23% covered (warning)
75.23%
167 / 222
51.02% covered (warning)
51.02%
25 / 49
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module
75.23% covered (warning)
75.23%
167 / 222
51.02% covered (warning)
51.02%
25 / 49
213.17
0.00% covered (danger)
0.00%
0 / 1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSkinStylesOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDependencyAccessCallbacks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOrigin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFlip
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDeprecationInformation
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getDeprecationWarning
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getScript
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setHookContainer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHookRunner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScriptURLsForDebug
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStyleURLsForDebug
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMessages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSource
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDependencies
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkins
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkipFunction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresES6
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileDependencies
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 setFileDependencies
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 saveFileDependencies
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 getRelativePaths
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 expandRelativePaths
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getMessageBlob
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 setMessageBlob
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaders
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPreloadLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLessVars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildContent
75.00% covered (warning)
75.00%
39 / 52
0.00% covered (danger)
0.00%
0 / 1
24.64
 getVersionHash
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 enableModuleContentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefinitionSummary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isKnownEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldEmbedModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldSkipStructureTest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateScriptFile
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
4
 safeFileHash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVary
100.00% covered (success)
100.00%
4 / 4
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 */
22
23namespace MediaWiki\ResourceLoader;
24
25use Exception;
26use FileContentsHasher;
27use LogicException;
28use MediaWiki\Config\Config;
29use MediaWiki\HookContainer\HookContainer;
30use MediaWiki\MainConfigNames;
31use MediaWiki\MediaWikiServices;
32use Peast\Peast;
33use Peast\Syntax\Exception as PeastSyntaxException;
34use Psr\Log\LoggerAwareInterface;
35use Psr\Log\LoggerInterface;
36use Psr\Log\NullLogger;
37use RuntimeException;
38use Wikimedia\RelPath;
39use Wikimedia\RequestTimeout\TimeoutException;
40
41/**
42 * Abstraction for ResourceLoader modules, with name registration and maxage functionality.
43 *
44 * @see $wgResourceModules for the available options when registering a module.
45 * @stable to extend
46 * @ingroup ResourceLoader
47 * @since 1.17
48 */
49abstract class Module implements LoggerAwareInterface {
50    /** @var Config */
51    protected $config;
52    /** @var LoggerInterface */
53    protected $logger;
54
55    /**
56     * Script and style modules form a hierarchy of trustworthiness, with core modules
57     * like skins and jQuery as most trustworthy, and user scripts as least trustworthy. We can
58     * limit the types of scripts and styles we allow to load on, say, sensitive special
59     * pages like Special:UserLogin and Special:Preferences
60     * @var int
61     */
62    protected $origin = self::ORIGIN_CORE_SITEWIDE;
63
64    /** @var string|null Module name */
65    protected $name = null;
66    /** @var string[]|null Skin names */
67    protected $skins = null;
68
69    /** @var array Map of (variant => indirect file dependencies) */
70    protected $fileDeps = [];
71    /** @var array Map of (language => in-object cache for message blob) */
72    protected $msgBlobs = [];
73    /** @var array Map of (context hash => cached module version hash) */
74    protected $versionHash = [];
75    /** @var array Map of (context hash => cached module content) */
76    protected $contents = [];
77
78    /** @var HookRunner|null */
79    private $hookRunner;
80
81    /** @var callback Function of (module name, variant) to get indirect file dependencies */
82    private $depLoadCallback;
83    /** @var callback Function of (module name, variant) to get indirect file dependencies */
84    private $depSaveCallback;
85
86    /** @var string|bool Deprecation string or true if deprecated; false otherwise */
87    protected $deprecated = false;
88
89    /** @var string Scripts only */
90    public const TYPE_SCRIPTS = 'scripts';
91    /** @var string Styles only */
92    public const TYPE_STYLES = 'styles';
93    /** @var string Scripts and styles */
94    public const TYPE_COMBINED = 'combined';
95
96    /** @var string */
97    public const GROUP_SITE = 'site';
98    /** @var string */
99    public const GROUP_USER = 'user';
100    /** @var string */
101    public const GROUP_PRIVATE = 'private';
102    /** @var string */
103    public const GROUP_NOSCRIPT = 'noscript';
104
105    /** @var string Module only has styles (loaded via <style> or <link rel=stylesheet>) */
106    public const LOAD_STYLES = 'styles';
107    /** @var string Module may have other resources (loaded via mw.loader from a script) */
108    public const LOAD_GENERAL = 'general';
109
110    /** @var int Sitewide core module like a skin file or jQuery component */
111    public const ORIGIN_CORE_SITEWIDE = 1;
112    /** @var int Per-user module generated by the software */
113    public const ORIGIN_CORE_INDIVIDUAL = 2;
114    /**
115     * Sitewide module generated from user-editable files, like MediaWiki:Common.js,
116     * or modules accessible to multiple users, such as those generated by the Gadgets extension.
117     * @var int
118     */
119    public const ORIGIN_USER_SITEWIDE = 3;
120    /** @var int Per-user module generated from user-editable files, like User:Me/vector.js */
121    public const ORIGIN_USER_INDIVIDUAL = 4;
122    /** @var int An access constant; make sure this is kept as the largest number in this group */
123    public const ORIGIN_ALL = 10;
124
125    /** @var int Cache version for user-script JS validation errors from validateScriptFile(). */
126    private const USERJSPARSE_CACHE_VERSION = 3;
127
128    /**
129     * Get this module's name. This is set when the module is registered
130     * with ResourceLoader::register()
131     *
132     * @return string|null Name (string) or null if no name was set
133     */
134    public function getName() {
135        return $this->name;
136    }
137
138    /**
139     * Set this module's name. This is called by ResourceLoader::register()
140     * when registering the module. Other code should not call this.
141     *
142     * @param string $name
143     */
144    public function setName( $name ) {
145        $this->name = $name;
146    }
147
148    /**
149     * Provide overrides for skinStyles to modules that support that.
150     *
151     * This MUST be called after self::setName().
152     *
153     * @since 1.37
154     * @see $wgResourceModuleSkinStyles
155     * @param array $moduleSkinStyles
156     */
157    public function setSkinStylesOverride( array $moduleSkinStyles ): void {
158        // Stub, only supported by FileModule currently.
159    }
160
161    /**
162     * Inject the functions that load/save the indirect file path dependency list from storage
163     *
164     * @param callable $loadCallback Function of (module name, variant)
165     * @param callable $saveCallback Function of (module name, variant, current paths, stored paths)
166     * @since 1.35
167     */
168    public function setDependencyAccessCallbacks( callable $loadCallback, callable $saveCallback ) {
169        $this->depLoadCallback = $loadCallback;
170        $this->depSaveCallback = $saveCallback;
171    }
172
173    /**
174     * Get this module's origin. This is set when the module is registered
175     * with ResourceLoader::register()
176     *
177     * @return int Module class constant, the subclass default if not set manually
178     */
179    public function getOrigin() {
180        return $this->origin;
181    }
182
183    /**
184     * @param Context $context
185     * @return bool
186     */
187    public function getFlip( Context $context ) {
188        return MediaWikiServices::getInstance()->getContentLanguage()->getDir() !==
189            $context->getDirection();
190    }
191
192    /**
193     * Get JS representing deprecation information for the current module if available
194     *
195     * @deprecated since 1.41 use getDeprecationWarning()
196     *
197     * @param Context $context
198     * @return string JavaScript code
199     */
200    public function getDeprecationInformation( Context $context ) {
201        wfDeprecated( __METHOD__, '1.41' );
202        $warning = $this->getDeprecationWarning();
203        if ( $warning === null ) {
204            return '';
205        }
206        return 'mw.log.warn(' . $context->encodeJson( $warning ) . ');';
207    }
208
209    /**
210     * Get the deprecation warning, if any
211     *
212     * @since 1.41
213     * @return string|null
214     */
215    public function getDeprecationWarning() {
216        if ( !$this->deprecated ) {
217            return null;
218        }
219        $name = $this->getName();
220        $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".';
221        if ( is_string( $this->deprecated ) ) {
222            $warning .= "\n" . $this->deprecated;
223        }
224        return $warning;
225    }
226
227    /**
228     * Get all JS for this module for a given language and skin.
229     * Includes all relevant JS except loader scripts.
230     *
231     * For multi-file modules where require() is used to load one file from
232     * another file, this should return an array structured as follows:
233     * [
234     *     'files' => [
235     *         'file1.js' => [ 'type' => 'script', 'content' => 'JS code' ],
236     *         'file2.js' => [ 'type' => 'script', 'content' => 'JS code' ],
237     *         'data.json' => [ 'type' => 'data', 'content' => array ]
238     *     ],
239     *     'main' => 'file1.js'
240     * ]
241     *
242     * For plain concatenated scripts, this can either return a string, or an
243     * associative array similar to the one used for package files:
244     * [
245     *     'plainScripts' => [
246     *         [ 'content' => 'JS code' ],
247     *         [ 'content' => 'JS code' ],
248     *     ],
249     * ]
250     *
251     * @stable to override
252     * @param Context $context
253     * @return string|array JavaScript code (string), or multi-file array with the
254     *   following keys:
255     *     - files: An associative array mapping file name to file info structure
256     *     - main: The name of the main script, a key in the files array
257     *     - plainScripts: An array of file info structures to be concatenated and
258     *       executed when the module is loaded.
259     *   Each file info structure has the following keys:
260     *     - type: May be "script", "script-vue" or "data". Optional, default "script".
261     *     - content: The string content of the file
262     *     - filePath: A FilePath object describing the location of the source file.
263     *       This will be used to construct the source map during minification.
264     */
265    public function getScript( Context $context ) {
266        // Stub, override expected
267        return '';
268    }
269
270    /**
271     * Takes named templates by the module and returns an array mapping.
272     *
273     * @stable to override
274     * @return string[] Array of templates mapping template alias to content
275     */
276    public function getTemplates() {
277        // Stub, override expected.
278        return [];
279    }
280
281    /**
282     * @return Config
283     * @since 1.24
284     */
285    public function getConfig() {
286        if ( $this->config === null ) {
287            throw new RuntimeException( 'Config accessed before it is set' );
288        }
289
290        return $this->config;
291    }
292
293    /**
294     * @param Config $config
295     * @since 1.24
296     */
297    public function setConfig( Config $config ) {
298        $this->config = $config;
299    }
300
301    /**
302     * @since 1.27
303     * @param LoggerInterface $logger
304     */
305    public function setLogger( LoggerInterface $logger ) {
306        $this->logger = $logger;
307    }
308
309    /**
310     * @since 1.27
311     * @return LoggerInterface
312     */
313    protected function getLogger() {
314        if ( !$this->logger ) {
315            $this->logger = new NullLogger();
316        }
317        return $this->logger;
318    }
319
320    /**
321     * @internal For use only by ResourceLoader::getModule
322     * @param HookContainer $hookContainer
323     */
324    public function setHookContainer( HookContainer $hookContainer ): void {
325        $this->hookRunner = new HookRunner( $hookContainer );
326    }
327
328    /**
329     * Get a HookRunner for running core hooks.
330     *
331     * @internal For use only within core Module subclasses. Hook interfaces may be removed
332     *   without notice.
333     * @return HookRunner
334     */
335    protected function getHookRunner(): HookRunner {
336        return $this->hookRunner;
337    }
338
339    /**
340     * Get alternative script URLs for legacy debug mode.
341     *
342     * The default behavior is to return a `load.php?only=scripts&module=<name>` URL.
343     *
344     * Module classes that merely wrap one or more other script files in production mode, may
345     * override this method to return an array of raw URLs for those underlying scripts,
346     * if those are individually web-accessible.
347     *
348     * The mw.loader client will load and execute each URL consecutively. This has the caveat of
349     * executing legacy debug scripts in the global scope, which is why non-package file modules
350     * tend to use file closures (T50886).
351     *
352     * This function MUST NOT be called, unless all the following are true:
353     *
354     * 1. We're in debug mode,
355     * 2. There is no `only=` parameter in the context,
356     * 3. self::supportsURLLoading() has returned true.
357     *
358     * Point 2 prevents an infinite loop since we use the `only=` mechanism in the return value.
359     * Overrides must similarly return with `only`, or return or a non-load.php URL.
360     *
361     * @stable to override
362     * @param Context $context
363     * @return string[]
364     */
365    public function getScriptURLsForDebug( Context $context ) {
366        $rl = $context->getResourceLoader();
367        $derivative = new DerivativeContext( $context );
368        $derivative->setModules( [ $this->getName() ] );
369        $derivative->setOnly( 'scripts' );
370
371        $url = $rl->createLoaderURL(
372            $this->getSource(),
373            $derivative
374        );
375
376        // Expand debug URL in case we are another wiki's module source (T255367)
377        $url = $rl->expandUrl( $this->getConfig()->get( MainConfigNames::Server ), $url );
378
379        return [ $url ];
380    }
381
382    /**
383     * Whether this module supports URL loading. If this function returns false,
384     * getScript() will be used even in cases (debug mode, no only param) where
385     * getScriptURLsForDebug() would normally be used instead.
386     *
387     * @stable to override
388     * @return bool
389     */
390    public function supportsURLLoading() {
391        return true;
392    }
393
394    /**
395     * Get all CSS for this module for a given skin.
396     *
397     * @stable to override
398     * @param Context $context
399     * @return array List of CSS strings or array of CSS strings keyed by media type.
400     *  like [ 'screen' => '.foo { width: 0 }' ];
401     *  or [ 'screen' => [ '.foo { width: 0 }' ] ];
402     */
403    public function getStyles( Context $context ) {
404        // Stub, override expected
405        return [];
406    }
407
408    /**
409     * Get the URL or URLs to load for this module's CSS in debug mode.
410     * The default behavior is to return a load.php?only=styles URL for
411     * the module, but file-based modules will want to override this to
412     * load the files directly
413     *
414     * This function must only be called when:
415     *
416     * 1. We're in debug mode,
417     * 2. There is no `only=` parameter and,
418     * 3. self::supportsURLLoading() returns true.
419     *
420     * See also getScriptURLsForDebug().
421     *
422     * @stable to override
423     * @param Context $context
424     * @return array [ mediaType => [ URL1, URL2, ... ], ... ]
425     */
426    public function getStyleURLsForDebug( Context $context ) {
427        $resourceLoader = $context->getResourceLoader();
428        $derivative = new DerivativeContext( $context );
429        $derivative->setModules( [ $this->getName() ] );
430        $derivative->setOnly( 'styles' );
431
432        $url = $resourceLoader->createLoaderURL(
433            $this->getSource(),
434            $derivative
435        );
436
437        return [ 'all' => [ $url ] ];
438    }
439
440    /**
441     * Get the messages needed for this module.
442     *
443     * To get a JSON blob with messages, use MessageBlobStore::get()
444     *
445     * @stable to override
446     * @return string[] List of message keys. Keys may occur more than once
447     */
448    public function getMessages() {
449        // Stub, override expected
450        return [];
451    }
452
453    /**
454     * Get the group this module is in.
455     *
456     * @stable to override
457     * @return string|null Group name
458     */
459    public function getGroup() {
460        // Stub, override expected
461        return null;
462    }
463
464    /**
465     * Get the source of this module. Should only be overridden for foreign modules.
466     *
467     * @stable to override
468     * @return string Source name, 'local' for local modules
469     */
470    public function getSource() {
471        // Stub, override expected
472        return 'local';
473    }
474
475    /**
476     * Get a list of modules this module depends on.
477     *
478     * Dependency information is taken into account when loading a module
479     * on the client side.
480     *
481     * Note: It is expected that $context will be made non-optional in the near
482     * future.
483     *
484     * @stable to override
485     * @param Context|null $context
486     * @return string[] List of module names as strings
487     */
488    public function getDependencies( Context $context = null ) {
489        // Stub, override expected
490        return [];
491    }
492
493    /**
494     * Get list of skins for which this module must be available to load.
495     *
496     * By default, modules are available to all skins.
497     *
498     * This information may be used by the startup module to optimise registrations
499     * based on the current skin.
500     *
501     * @stable to override
502     * @since 1.39
503     * @return string[]|null
504     */
505    public function getSkins(): ?array {
506        return $this->skins;
507    }
508
509    /**
510     * Get the module's load type.
511     *
512     * @stable to override
513     * @since 1.28
514     * @return string Module LOAD_* constant
515     */
516    public function getType() {
517        return self::LOAD_GENERAL;
518    }
519
520    /**
521     * Get the skip function.
522     *
523     * Modules that provide fallback functionality can provide a "skip function". This
524     * function, if provided, will be passed along to the module registry on the client.
525     * When this module is loaded (either directly or as a dependency of another module),
526     * then this function is executed first. If the function returns true, the module will
527     * instantly be considered "ready" without requesting the associated module resources.
528     *
529     * The value returned here must be valid javascript for execution in a private function.
530     * It must not contain the "function () {" and "}" wrapper though.
531     *
532     * @stable to override
533     * @return string|null A JavaScript function body returning a boolean value, or null
534     */
535    public function getSkipFunction() {
536        return null;
537    }
538
539    /**
540     * Whether the module requires ES6 support in the client.
541     *
542     * If the client does not support ES6, attempting to load a module that requires ES6 will
543     * result in an error.
544     *
545     * @deprecated since 1.41, ignored by ResourceLoader
546     * @since 1.36
547     * @return bool
548     */
549    public function requiresES6() {
550        return true;
551    }
552
553    /**
554     * Get the indirect dependencies for this module pursuant to the skin/language context
555     *
556     * These are only image files referenced by the module's stylesheet
557     *
558     * If neither setFileDependencies() nor setDependencyAccessCallbacks() was called,
559     * this will simply return a placeholder with an empty file list
560     *
561     * @see Module::setFileDependencies()
562     * @see Module::saveFileDependencies()
563     * @param Context $context
564     * @return string[] List of absolute file paths
565     */
566    protected function getFileDependencies( Context $context ) {
567        $variant = self::getVary( $context );
568
569        if ( !isset( $this->fileDeps[$variant] ) ) {
570            if ( $this->depLoadCallback ) {
571                $this->fileDeps[$variant] =
572                    call_user_func( $this->depLoadCallback, $this->getName(), $variant );
573            } else {
574                $this->getLogger()->info( __METHOD__ . ": no callback registered" );
575                $this->fileDeps[$variant] = [];
576            }
577        }
578
579        return $this->fileDeps[$variant];
580    }
581
582    /**
583     * Set the indirect dependencies for this module pursuant to the skin/language context
584     *
585     * These are only image files referenced by the module's stylesheet
586     *
587     * @see Module::getFileDependencies()
588     * @see Module::saveFileDependencies()
589     * @param Context $context
590     * @param string[] $paths List of absolute file paths
591     */
592    public function setFileDependencies( Context $context, array $paths ) {
593        $variant = self::getVary( $context );
594        $this->fileDeps[$variant] = $paths;
595    }
596
597    /**
598     * Save the indirect dependencies for this module pursuant to the skin/language context
599     *
600     * @param Context $context
601     * @param string[] $curFileRefs List of newly computed indirect file dependencies
602     * @since 1.27
603     */
604    protected function saveFileDependencies( Context $context, array $curFileRefs ) {
605        if ( !$this->depSaveCallback ) {
606            $this->getLogger()->info( __METHOD__ . ": no callback registered" );
607
608            return;
609        }
610
611        try {
612            // Pitfalls and performance considerations:
613            // 1. Don't keep updating the tracked paths due to duplicates or sorting.
614            // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481)
615            // 3. Don't needlessly replace tracked paths with the same value
616            //    just because $IP changed (e.g. when upgrading a wiki).
617            // 4. Don't create an endless replace loop on every request for this
618            //    module when '../' is used anywhere. Even though both are expanded
619            //    (one expanded by getFileDependencies from the DB, the other is
620            //    still raw as originally read by RL), the latter has not
621            //    been normalized yet.
622            call_user_func(
623                $this->depSaveCallback,
624                $this->getName(),
625                self::getVary( $context ),
626                self::getRelativePaths( $curFileRefs ),
627                self::getRelativePaths( $this->getFileDependencies( $context ) )
628            );
629        } catch ( TimeoutException $e ) {
630            throw $e;
631        } catch ( Exception $e ) {
632            $this->getLogger()->warning(
633                __METHOD__ . ": failed to update dependencies: {$e->getMessage()}",
634                [ 'exception' => $e ]
635            );
636        }
637    }
638
639    /**
640     * Make file paths relative to MediaWiki directory.
641     *
642     * This is used to make file paths safe for storing in a database without the paths
643     * becoming stale or incorrect when MediaWiki is moved or upgraded (T111481).
644     *
645     * @since 1.27
646     * @param array $filePaths
647     * @return array
648     */
649    public static function getRelativePaths( array $filePaths ) {
650        global $IP;
651        return array_map( static function ( $path ) use ( $IP ) {
652            return RelPath::getRelativePath( $path, $IP );
653        }, $filePaths );
654    }
655
656    /**
657     * Expand directories relative to $IP.
658     *
659     * @since 1.27
660     * @param array $filePaths
661     * @return array
662     */
663    public static function expandRelativePaths( array $filePaths ) {
664        global $IP;
665        return array_map( static function ( $path ) use ( $IP ) {
666            return RelPath::joinPath( $IP, $path );
667        }, $filePaths );
668    }
669
670    /**
671     * Get the hash of the message blob.
672     *
673     * @stable to override
674     * @since 1.27
675     * @param Context $context
676     * @return string|null JSON blob or null if module has no messages
677     * @return-taint none -- do not propagate taint from $context->getLanguage()
678     */
679    protected function getMessageBlob( Context $context ) {
680        if ( !$this->getMessages() ) {
681            // Don't bother consulting MessageBlobStore
682            return null;
683        }
684        // Message blobs may only vary language, not by context keys
685        $lang = $context->getLanguage();
686        if ( !isset( $this->msgBlobs[$lang] ) ) {
687            $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [
688                'module' => $this->getName(),
689            ] );
690            $store = $context->getResourceLoader()->getMessageBlobStore();
691            $this->msgBlobs[$lang] = $store->getBlob( $this, $lang );
692        }
693        return $this->msgBlobs[$lang];
694    }
695
696    /**
697     * Set in-object cache for message blobs.
698     *
699     * Used to allow fetching of message blobs in batches. See ResourceLoader::preloadModuleInfo().
700     *
701     * @since 1.27
702     * @param string|null $blob JSON blob or null
703     * @param string $lang Language code
704     */
705    public function setMessageBlob( $blob, $lang ) {
706        $this->msgBlobs[$lang] = $blob;
707    }
708
709    /**
710     * Get headers to send as part of a module web response.
711     *
712     * It is not supported to send headers through this method that are
713     * required to be unique or otherwise sent once in an HTTP response
714     * because clients may make batch requests for multiple modules (as
715     * is the default behaviour for ResourceLoader clients).
716     *
717     * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders().
718     *
719     * @since 1.30
720     * @param Context $context
721     * @return string[] Array of HTTP response headers
722     */
723    final public function getHeaders( Context $context ) {
724        $formattedLinks = [];
725        foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) {
726            $link = "<{$url}>;rel=preload";
727            foreach ( $attribs as $key => $val ) {
728                $link .= ";{$key}={$val}";
729            }
730            $formattedLinks[] = $link;
731        }
732        if ( $formattedLinks ) {
733            return [ 'Link: ' . implode( ',', $formattedLinks ) ];
734        }
735        return [];
736    }
737
738    /**
739     * Get a list of resources that web browsers may preload.
740     *
741     * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>.
742     *
743     * Use case for ResourceLoader originally part of T164299.
744     *
745     * @par Example
746     * @code
747     *     protected function getPreloadLinks() {
748     *         return [
749     *             'https://example.org/script.js' => [ 'as' => 'script' ],
750     *             'https://example.org/image.png' => [ 'as' => 'image' ],
751     *         ];
752     *     }
753     * @endcode
754     *
755     * @par Example using HiDPI image variants
756     * @code
757     *     protected function getPreloadLinks() {
758     *         return [
759     *             'https://example.org/logo.png' => [
760     *                 'as' => 'image',
761     *                 'media' => 'not all and (min-resolution: 2dppx)',
762     *             ],
763     *             'https://example.org/logo@2x.png' => [
764     *                 'as' => 'image',
765     *                 'media' => '(min-resolution: 2dppx)',
766     *             ],
767     *         ];
768     *     }
769     * @endcode
770     *
771     * @see Module::getHeaders
772     *
773     * @stable to override
774     * @since 1.30
775     * @param Context $context
776     * @return array Keyed by url, values must be an array containing
777     *  at least an 'as' key. Optionally a 'media' key as well.
778     *
779     */
780    protected function getPreloadLinks( Context $context ) {
781        return [];
782    }
783
784    /**
785     * Get module-specific LESS variables, if any.
786     *
787     * @stable to override
788     * @since 1.27
789     * @param Context $context
790     * @return array Module-specific LESS variables.
791     */
792    protected function getLessVars( Context $context ) {
793        return [];
794    }
795
796    /**
797     * Get an array of this module's resources. Ready for serving to the web.
798     *
799     * @since 1.26
800     * @param Context $context
801     * @return array
802     */
803    public function getModuleContent( Context $context ) {
804        $contextHash = $context->getHash();
805        // Cache this expensive operation. This calls builds the scripts, styles, and messages
806        // content which typically involves filesystem and/or database access.
807        if ( !array_key_exists( $contextHash, $this->contents ) ) {
808            $this->contents[$contextHash] = $this->buildContent( $context );
809        }
810        return $this->contents[$contextHash];
811    }
812
813    /**
814     * Bundle all resources attached to this module into an array.
815     *
816     * @since 1.26
817     * @param Context $context
818     * @return array
819     */
820    final protected function buildContent( Context $context ) {
821        $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
822        $statStart = microtime( true );
823
824        // This MUST build both scripts and styles, regardless of whether $context->getOnly()
825        // is 'scripts' or 'styles' because the result is used by getVersionHash which
826        // must be consistent regardless of the 'only' filter on the current request.
827        // Also, when introducing new module content resources (e.g. templates, headers),
828        // these should only be included in the array when they are non-empty so that
829        // existing modules not using them do not get their cache invalidated.
830        $content = [];
831
832        // Scripts
833        if ( $context->getDebug() === $context::DEBUG_LEGACY && !$context->getOnly() && $this->supportsURLLoading() ) {
834            // In legacy debug mode, let supporting modules like FileModule replace the bundled
835            // script closure with an array of alternative script URLs to consecutively load instead.
836            // See self::getScriptURLsForDebug() more details.
837            $scripts = $this->getScriptURLsForDebug( $context );
838        } else {
839            $scripts = $this->getScript( $context );
840            if ( is_string( $scripts ) ) {
841                $scripts = [ 'plainScripts' => [ [ 'content' => $scripts ] ] ];
842            }
843        }
844        $content['scripts'] = $scripts;
845
846        $styles = [];
847        // Don't create empty stylesheets like [ '' => '' ] for modules
848        // that don't *have* any stylesheets (T40024).
849        $stylePairs = $this->getStyles( $context );
850        if ( count( $stylePairs ) ) {
851            // If we are in debug mode without &only= set, we'll want to return an array of URLs
852            // See comment near shouldIncludeScripts() for more details
853            if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
854                $styles = [
855                    'url' => $this->getStyleURLsForDebug( $context )
856                ];
857            } else {
858                // Minify CSS before embedding in mw.loader.impl call
859                // (unless in debug mode)
860                if ( !$context->getDebug() ) {
861                    foreach ( $stylePairs as $media => $style ) {
862                        // Can be either a string or an array of strings.
863                        if ( is_array( $style ) ) {
864                            $stylePairs[$media] = [];
865                            foreach ( $style as $cssText ) {
866                                if ( is_string( $cssText ) ) {
867                                    $stylePairs[$media][] =
868                                        ResourceLoader::filter( 'minify-css', $cssText );
869                                }
870                            }
871                        } elseif ( is_string( $style ) ) {
872                            $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
873                        }
874                    }
875                }
876                // Wrap styles into @media groups as needed and flatten into a numerical array
877                $styles = [
878                    'css' => ResourceLoader::makeCombinedStyles( $stylePairs )
879                ];
880            }
881        }
882        $content['styles'] = $styles;
883
884        // Messages
885        $blob = $this->getMessageBlob( $context );
886        if ( $blob ) {
887            $content['messagesBlob'] = $blob;
888        }
889
890        $templates = $this->getTemplates();
891        if ( $templates ) {
892            $content['templates'] = $templates;
893        }
894
895        $headers = $this->getHeaders( $context );
896        if ( $headers ) {
897            $content['headers'] = $headers;
898        }
899
900        $deprecationWarning = $this->getDeprecationWarning();
901        if ( $deprecationWarning !== null ) {
902            $content['deprecationWarning'] = $deprecationWarning;
903        }
904
905        $statTiming = microtime( true ) - $statStart;
906        $statName = strtr( $this->getName(), '.', '_' );
907
908        $statsFactory->getTiming( 'resourceloader_build_seconds' )
909            ->setLabel( 'name', $statName )
910            ->copyToStatsdAt( [
911                'resourceloader_build.all',
912                "resourceloader_build.$statName",
913            ] )
914            ->observe( 1000 * $statTiming );
915
916        return $content;
917    }
918
919    /**
920     * Get a string identifying the current version of this module in a given context.
921     *
922     * Whenever anything happens that changes the module's response (e.g. scripts, styles, and
923     * messages) this value must change. This value is used to store module responses in caches,
924     * both server-side (by a CDN, or other HTTP cache), and client-side (in `mw.loader.store`,
925     * and in the browser's own HTTP cache).
926     *
927     * The underlying methods called here for any given module should be quick because this
928     * is called for potentially thousands of module bundles in the same request as part of the
929     * StartUpModule, which is how we invalidate caches and propagate changes to clients.
930     *
931     * @since 1.26
932     * @see self::getDefinitionSummary for how to customize version computation.
933     * @param Context $context
934     * @return string Hash formatted by ResourceLoader::makeHash
935     */
936    final public function getVersionHash( Context $context ) {
937        if ( $context->getDebug() ) {
938            // In debug mode, make uncached startup module extra fast by not computing any hashes.
939            // Server responses from load.php for individual modules already have no-cache so
940            // we don't need them. This also makes breakpoint debugging easier, as each module
941            // gets its own consistent URL. (T235672)
942            return '';
943        }
944
945        // Cache this somewhat expensive operation. Especially because some classes
946        // (e.g. startup module) iterate more than once over all modules to get versions.
947        $contextHash = $context->getHash();
948        if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
949            if ( $this->enableModuleContentVersion() ) {
950                // Detect changes directly by hashing the module contents.
951                $str = json_encode( $this->getModuleContent( $context ) );
952            } else {
953                // Infer changes based on definition and other metrics
954                $summary = $this->getDefinitionSummary( $context );
955                if ( !isset( $summary['_class'] ) ) {
956                    throw new LogicException( 'getDefinitionSummary must call parent method' );
957                }
958                $str = json_encode( $summary );
959            }
960
961            $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str );
962        }
963        return $this->versionHash[$contextHash];
964    }
965
966    /**
967     * Whether to generate version hash based on module content.
968     *
969     * If a module requires database or file system access to build the module
970     * content, consider disabling this in favour of manually tracking relevant
971     * aspects in getDefinitionSummary(). See getVersionHash() for how this is used.
972     *
973     * @stable to override
974     * @return bool
975     */
976    public function enableModuleContentVersion() {
977        return false;
978    }
979
980    /**
981     * Get the definition summary for this module.
982     *
983     * This is the method subclasses are recommended to use to track data that
984     * should influence the module's version hash.
985     *
986     * Subclasses must call the parent getDefinitionSummary() and add to the
987     * returned array. It is recommended that each subclass appends its own array,
988     * to prevent clashes or accidental overwrites of array keys from the parent
989     * class. This gives each subclass a clean scope.
990     *
991     * @code
992     *     $summary = parent::getDefinitionSummary( $context );
993     *     $summary[] = [
994     *         'foo' => 123,
995     *         'bar' => 'quux',
996     *     ];
997     *     return $summary;
998     * @endcode
999     *
1000     * Return an array that contains all significant properties that define the
1001     * module. The returned data should be deterministic and only change when
1002     * the generated module response would change. Prefer content hashes over
1003     * modified timestamps because timestamps may change for unrelated reasons
1004     * and are not deterministic (T102578). For example, because timestamps are
1005     * not stored in Git, each branch checkout would cause all files to appear as
1006     * new. Timestamps also tend to not match between servers causing additional
1007     * ever-lasting churning of the version hash.
1008     *
1009     * Be careful not to normalise the data too much in an effort to be deterministic.
1010     * For example, if a module concatenates files together (order is significant),
1011     * then the definition summary could be a list of file names, and a list of
1012     * file hashes. These lists should not be sorted as that would mean the cache
1013     * is not invalidated when the order changes (T39812).
1014     *
1015     * This data structure must exclusively contain primitive "scalar" values,
1016     * as it will be serialised using `json_encode`.
1017     *
1018     * @stable to override
1019     * @since 1.23
1020     * @param Context $context
1021     * @return array|null
1022     */
1023    public function getDefinitionSummary( Context $context ) {
1024        return [
1025            '_class' => static::class,
1026            // Make sure that when filter cache for minification is invalidated,
1027            // we also change the HTTP urls and mw.loader.store keys (T176884).
1028            '_cacheVersion' => ResourceLoader::CACHE_VERSION,
1029        ];
1030    }
1031
1032    /**
1033     * Check whether this module is known to be empty. If a child class
1034     * has an easy and cheap way to determine that this module is
1035     * definitely going to be empty, it should override this method to
1036     * return true in that case. Callers may optimize the request for this
1037     * module away if this function returns true.
1038     *
1039     * @stable to override
1040     * @param Context $context
1041     * @return bool
1042     */
1043    public function isKnownEmpty( Context $context ) {
1044        return false;
1045    }
1046
1047    /**
1048     * Check whether this module should be embedded rather than linked
1049     *
1050     * Modules returning true here will be embedded rather than loaded by
1051     * ClientHtml.
1052     *
1053     * @since 1.30
1054     * @stable to override
1055     * @param Context $context
1056     * @return bool
1057     */
1058    public function shouldEmbedModule( Context $context ) {
1059        return $this->getGroup() === self::GROUP_PRIVATE;
1060    }
1061
1062    /**
1063     * Whether to skip the structure test ResourcesTest::testRespond() for this
1064     * module.
1065     *
1066     * @since 1.42
1067     * @stable to override
1068     * @return bool
1069     */
1070    public function shouldSkipStructureTest() {
1071        return $this->getGroup() === self::GROUP_PRIVATE;
1072    }
1073
1074    /**
1075     * Validate a user-provided JavaScript blob.
1076     *
1077     * @param string $fileName
1078     * @param string $contents JavaScript code
1079     * @return string JavaScript code, either the original content or a replacement
1080     *  that uses `mw.log.error()` to communicate a syntax error.
1081     */
1082    protected function validateScriptFile( $fileName, $contents ) {
1083        if ( !$this->getConfig()->get( MainConfigNames::ResourceLoaderValidateJS ) ) {
1084            return $contents;
1085        }
1086        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1087        // Cache potentially slow parsing of JavaScript code during the
1088        // critical path. This happens lazily when responding to requests
1089        // for modules=site, modules=user, and Gadgets.
1090        $error = $cache->getWithSetCallback(
1091            $cache->makeKey(
1092                'resourceloader-userjsparse',
1093                self::USERJSPARSE_CACHE_VERSION,
1094                md5( $contents ),
1095                $fileName
1096            ),
1097            $cache::TTL_WEEK,
1098            static function () use ( $contents, $fileName ) {
1099                try {
1100                    Peast::ES2016( $contents )->parse();
1101                } catch ( PeastSyntaxException $e ) {
1102                    return $e->getMessage() . " on line " . $e->getPosition()->getLine();
1103                }
1104                // Cache success as null
1105                return null;
1106            }
1107        );
1108
1109        if ( $error ) {
1110            // Send the error to the browser console client-side.
1111            // By returning this as replacement for the actual script,
1112            // we ensure user-provided scripts are safe to include in a batch
1113            // request, without risk of a syntax error in this blob breaking
1114            // the response itself.
1115            return 'mw.log.error(' .
1116                json_encode(
1117                    'Parse error: ' . $error
1118                ) .
1119                ');';
1120        }
1121        return $contents;
1122    }
1123
1124    /**
1125     * Compute a non-cryptographic string hash of a file's contents.
1126     * If the file does not exist or cannot be read, returns an empty string.
1127     *
1128     * @since 1.26 Uses MD4 instead of SHA1.
1129     * @param string $filePath
1130     * @return string Hash
1131     */
1132    protected static function safeFileHash( $filePath ) {
1133        return FileContentsHasher::getFileContentsHash( $filePath );
1134    }
1135
1136    /**
1137     * Get vary string.
1138     *
1139     * @internal For internal use only.
1140     * @param Context $context
1141     * @return string
1142     */
1143    public static function getVary( Context $context ) {
1144        return implode( '|', [
1145            $context->getSkin(),
1146            $context->getLanguage(),
1147        ] );
1148    }
1149}