MediaWiki  master
ResourceLoaderModule.php
Go to the documentation of this file.
1 <?php
30 
37 abstract class ResourceLoaderModule implements LoggerAwareInterface {
39  protected $config;
41  protected $logger;
42 
50  protected $origin = self::ORIGIN_CORE_SITEWIDE;
51 
53  protected $name = null;
55  protected $targets = [ 'desktop' ];
56 
58  protected $fileDeps = [];
60  protected $msgBlobs = [];
62  protected $versionHash = [];
64  protected $contents = [];
65 
67  protected $deprecated = false;
68 
70  const TYPE_SCRIPTS = 'scripts';
72  const TYPE_STYLES = 'styles';
74  const TYPE_COMBINED = 'combined';
75 
77  const LOAD_STYLES = 'styles';
79  const LOAD_GENERAL = 'general';
80 
82  const ORIGIN_CORE_SITEWIDE = 1;
84  const ORIGIN_CORE_INDIVIDUAL = 2;
90  const ORIGIN_USER_SITEWIDE = 3;
92  const ORIGIN_USER_INDIVIDUAL = 4;
94  const ORIGIN_ALL = 10;
95 
102  public function getName() {
103  return $this->name;
104  }
105 
112  public function setName( $name ) {
113  $this->name = $name;
114  }
115 
123  public function getOrigin() {
124  return $this->origin;
125  }
126 
132  return MediaWikiServices::getInstance()->getContentLanguage()->getDir() !==
133  $context->getDirection();
134  }
135 
143  $deprecationInfo = $this->deprecated;
144  if ( $deprecationInfo ) {
145  $name = $this->getName();
146  $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".';
147  if ( is_string( $deprecationInfo ) ) {
148  $warning .= "\n" . $deprecationInfo;
149  }
150  return 'mw.log.warn(' . $context->encodeJson( $warning ) . ');';
151  } else {
152  return '';
153  }
154  }
155 
176  // Stub, override expected
177  return '';
178  }
179 
185  public function getTemplates() {
186  // Stub, override expected.
187  return [];
188  }
189 
194  public function getConfig() {
195  if ( $this->config === null ) {
196  // Ugh, fall back to default
197  $this->config = MediaWikiServices::getInstance()->getMainConfig();
198  }
199 
200  return $this->config;
201  }
202 
207  public function setConfig( Config $config ) {
208  $this->config = $config;
209  }
210 
215  public function setLogger( LoggerInterface $logger ) {
216  $this->logger = $logger;
217  }
218 
223  protected function getLogger() {
224  if ( !$this->logger ) {
225  $this->logger = new NullLogger();
226  }
227  return $this->logger;
228  }
229 
245  $resourceLoader = $context->getResourceLoader();
246  $derivative = new DerivativeResourceLoaderContext( $context );
247  $derivative->setModules( [ $this->getName() ] );
248  $derivative->setOnly( 'scripts' );
249  $derivative->setDebug( true );
250 
251  $url = $resourceLoader->createLoaderURL(
252  $this->getSource(),
253  $derivative
254  );
255 
256  return [ $url ];
257  }
258 
265  public function supportsURLLoading() {
266  return true;
267  }
268 
278  // Stub, override expected
279  return [];
280  }
281 
292  $resourceLoader = $context->getResourceLoader();
293  $derivative = new DerivativeResourceLoaderContext( $context );
294  $derivative->setModules( [ $this->getName() ] );
295  $derivative->setOnly( 'styles' );
296  $derivative->setDebug( true );
297 
298  $url = $resourceLoader->createLoaderURL(
299  $this->getSource(),
300  $derivative
301  );
302 
303  return [ 'all' => [ $url ] ];
304  }
305 
313  public function getMessages() {
314  // Stub, override expected
315  return [];
316  }
317 
323  public function getGroup() {
324  // Stub, override expected
325  return null;
326  }
327 
333  public function getSource() {
334  // Stub, override expected
335  return 'local';
336  }
337 
350  public function getDependencies( ResourceLoaderContext $context = null ) {
351  // Stub, override expected
352  return [];
353  }
354 
360  public function getTargets() {
361  return $this->targets;
362  }
363 
370  public function getType() {
371  return self::LOAD_GENERAL;
372  }
373 
388  public function getSkipFunction() {
389  return null;
390  }
391 
401  $vary = self::getVary( $context );
402 
403  // Try in-object cache first
404  if ( !isset( $this->fileDeps[$vary] ) ) {
405  $dbr = wfGetDB( DB_REPLICA );
406  $deps = $dbr->selectField( 'module_deps',
407  'md_deps',
408  [
409  'md_module' => $this->getName(),
410  'md_skin' => $vary,
411  ],
412  __METHOD__
413  );
414 
415  if ( !is_null( $deps ) ) {
416  $this->fileDeps[$vary] = self::expandRelativePaths(
417  (array)json_decode( $deps, true )
418  );
419  } else {
420  $this->fileDeps[$vary] = [];
421  }
422  }
423  return $this->fileDeps[$vary];
424  }
425 
436  $vary = self::getVary( $context );
437  $this->fileDeps[$vary] = $files;
438  }
439 
447  protected function saveFileDependencies( ResourceLoaderContext $context, array $localFileRefs ) {
448  try {
449  // Related bugs and performance considerations:
450  // 1. Don't needlessly change the database value with the same list in a
451  // different order or with duplicates.
452  // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481)
453  // 3. Don't needlessly replace the database with the same value
454  // just because $IP changed (e.g. when upgrading a wiki).
455  // 4. Don't create an endless replace loop on every request for this
456  // module when '../' is used anywhere. Even though both are expanded
457  // (one expanded by getFileDependencies from the DB, the other is
458  // still raw as originally read by RL), the latter has not
459  // been normalized yet.
460 
461  // Normalise
462  $localFileRefs = array_values( array_unique( $localFileRefs ) );
463  sort( $localFileRefs );
464  $localPaths = self::getRelativePaths( $localFileRefs );
465  $storedPaths = self::getRelativePaths( $this->getFileDependencies( $context ) );
466 
467  if ( $localPaths === $storedPaths ) {
468  // Unchanged. Avoid needless database query (especially master conn!).
469  return;
470  }
471 
472  // The file deps list has changed, we want to update it.
473  $vary = self::getVary( $context );
475  $key = $cache->makeKey( __METHOD__, $this->getName(), $vary );
476  $scopeLock = $cache->getScopedLock( $key, 0 );
477  if ( !$scopeLock ) {
478  // Another request appears to be doing this update already.
479  // Avoid write slams (T124649).
480  return;
481  }
482 
483  // No needless escaping as this isn't HTML output.
484  // Only stored in the database and parsed in PHP.
485  $deps = json_encode( $localPaths, JSON_UNESCAPED_SLASHES );
486  $dbw = wfGetDB( DB_MASTER );
487  $dbw->upsert( 'module_deps',
488  [
489  'md_module' => $this->getName(),
490  'md_skin' => $vary,
491  'md_deps' => $deps,
492  ],
493  [ [ 'md_module', 'md_skin' ] ],
494  [
495  'md_deps' => $deps,
496  ],
497  __METHOD__
498  );
499 
500  if ( $dbw->trxLevel() ) {
501  $dbw->onTransactionResolution(
502  function () use ( &$scopeLock ) {
503  ScopedCallback::consume( $scopeLock ); // release after commit
504  },
505  __METHOD__
506  );
507  }
508  } catch ( Exception $e ) {
509  // Probably a DB failure. Either the read query from getFileDependencies(),
510  // or the write query above.
511  wfDebugLog( 'resourceloader', __METHOD__ . ": failed to update DB: $e" );
512  }
513  }
514 
525  public static function getRelativePaths( array $filePaths ) {
526  global $IP;
527  return array_map( function ( $path ) use ( $IP ) {
528  return RelPath::getRelativePath( $path, $IP );
529  }, $filePaths );
530  }
531 
539  public static function expandRelativePaths( array $filePaths ) {
540  global $IP;
541  return array_map( function ( $path ) use ( $IP ) {
542  return RelPath::joinPath( $IP, $path );
543  }, $filePaths );
544  }
545 
554  if ( !$this->getMessages() ) {
555  // Don't bother consulting MessageBlobStore
556  return null;
557  }
558  // Message blobs may only vary language, not by context keys
559  $lang = $context->getLanguage();
560  if ( !isset( $this->msgBlobs[$lang] ) ) {
561  $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [
562  'module' => $this->getName(),
563  ] );
564  $store = $context->getResourceLoader()->getMessageBlobStore();
565  $this->msgBlobs[$lang] = $store->getBlob( $this, $lang );
566  }
567  return $this->msgBlobs[$lang];
568  }
569 
579  public function setMessageBlob( $blob, $lang ) {
580  $this->msgBlobs[$lang] = $blob;
581  }
582 
597  final public function getHeaders( ResourceLoaderContext $context ) {
598  $headers = [];
599 
600  $formattedLinks = [];
601  foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) {
602  $link = "<{$url}>;rel=preload";
603  foreach ( $attribs as $key => $val ) {
604  $link .= ";{$key}={$val}";
605  }
606  $formattedLinks[] = $link;
607  }
608  if ( $formattedLinks ) {
609  $headers[] = 'Link: ' . implode( ',', $formattedLinks );
610  }
611 
612  return $headers;
613  }
614 
655  return [];
656  }
657 
666  return [];
667  }
668 
677  $contextHash = $context->getHash();
678  // Cache this expensive operation. This calls builds the scripts, styles, and messages
679  // content which typically involves filesystem and/or database access.
680  if ( !array_key_exists( $contextHash, $this->contents ) ) {
681  $this->contents[$contextHash] = $this->buildContent( $context );
682  }
683  return $this->contents[$contextHash];
684  }
685 
693  final protected function buildContent( ResourceLoaderContext $context ) {
694  $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
695  $statStart = microtime( true );
696 
697  // This MUST build both scripts and styles, regardless of whether $context->getOnly()
698  // is 'scripts' or 'styles' because the result is used by getVersionHash which
699  // must be consistent regardless of the 'only' filter on the current request.
700  // Also, when introducing new module content resources (e.g. templates, headers),
701  // these should only be included in the array when they are non-empty so that
702  // existing modules not using them do not get their cache invalidated.
703  $content = [];
704 
705  // Scripts
706  // If we are in debug mode, we'll want to return an array of URLs if possible
707  // However, we can't do this if the module doesn't support it.
708  // We also can't do this if there is an only= parameter, because we have to give
709  // the module a way to return a load.php URL without causing an infinite loop
710  if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
711  $scripts = $this->getScriptURLsForDebug( $context );
712  } else {
713  $scripts = $this->getScript( $context );
714  // Make the script safe to concatenate by making sure there is at least one
715  // trailing new line at the end of the content. Previously, this looked for
716  // a semi-colon instead, but that breaks concatenation if the semicolon
717  // is inside a comment like "// foo();". Instead, simply use a
718  // line break as separator which matches JavaScript native logic for implicitly
719  // ending statements even if a semi-colon is missing.
720  // Bugs: T29054, T162719.
721  if ( is_string( $scripts )
722  && strlen( $scripts )
723  && substr( $scripts, -1 ) !== "\n"
724  ) {
725  $scripts .= "\n";
726  }
727  }
728  $content['scripts'] = $scripts;
729 
730  $styles = [];
731  // Don't create empty stylesheets like [ '' => '' ] for modules
732  // that don't *have* any stylesheets (T40024).
733  $stylePairs = $this->getStyles( $context );
734  if ( count( $stylePairs ) ) {
735  // If we are in debug mode without &only= set, we'll want to return an array of URLs
736  // See comment near shouldIncludeScripts() for more details
737  if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
738  $styles = [
739  'url' => $this->getStyleURLsForDebug( $context )
740  ];
741  } else {
742  // Minify CSS before embedding in mw.loader.implement call
743  // (unless in debug mode)
744  if ( !$context->getDebug() ) {
745  foreach ( $stylePairs as $media => $style ) {
746  // Can be either a string or an array of strings.
747  if ( is_array( $style ) ) {
748  $stylePairs[$media] = [];
749  foreach ( $style as $cssText ) {
750  if ( is_string( $cssText ) ) {
751  $stylePairs[$media][] =
752  ResourceLoader::filter( 'minify-css', $cssText );
753  }
754  }
755  } elseif ( is_string( $style ) ) {
756  $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
757  }
758  }
759  }
760  // Wrap styles into @media groups as needed and flatten into a numerical array
761  $styles = [
762  'css' => ResourceLoader::makeCombinedStyles( $stylePairs )
763  ];
764  }
765  }
766  $content['styles'] = $styles;
767 
768  // Messages
769  $blob = $this->getMessageBlob( $context );
770  if ( $blob ) {
771  $content['messagesBlob'] = $blob;
772  }
773 
774  $templates = $this->getTemplates();
775  if ( $templates ) {
776  $content['templates'] = $templates;
777  }
778 
779  $headers = $this->getHeaders( $context );
780  if ( $headers ) {
781  $content['headers'] = $headers;
782  }
783 
784  $statTiming = microtime( true ) - $statStart;
785  $statName = strtr( $this->getName(), '.', '_' );
786  $stats->timing( "resourceloader_build.all", 1000 * $statTiming );
787  $stats->timing( "resourceloader_build.$statName", 1000 * $statTiming );
788 
789  return $content;
790  }
791 
810  // Cache this somewhat expensive operation. Especially because some classes
811  // (e.g. startup module) iterate more than once over all modules to get versions.
812  $contextHash = $context->getHash();
813  if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
814  if ( $this->enableModuleContentVersion() ) {
815  // Detect changes directly by hashing the module contents.
816  $str = json_encode( $this->getModuleContent( $context ) );
817  } else {
818  // Infer changes based on definition and other metrics
819  $summary = $this->getDefinitionSummary( $context );
820  if ( !isset( $summary['_class'] ) ) {
821  throw new LogicException( 'getDefinitionSummary must call parent method' );
822  }
823  $str = json_encode( $summary );
824  }
825 
826  $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str );
827  }
828  return $this->versionHash[$contextHash];
829  }
830 
840  public function enableModuleContentVersion() {
841  return false;
842  }
843 
887  return [
888  '_class' => static::class,
889  // Make sure that when filter cache for minification is invalidated,
890  // we also change the HTTP urls and mw.loader.store keys (T176884).
891  '_cacheVersion' => ResourceLoader::CACHE_VERSION,
892  ];
893  }
894 
905  return false;
906  }
907 
919  return $this->getGroup() === 'private';
920  }
921 
923  private static $jsParser;
924  private static $parseCacheVersion = 1;
925 
934  protected function validateScriptFile( $fileName, $contents ) {
935  if ( !$this->getConfig()->get( 'ResourceLoaderValidateJS' ) ) {
936  return $contents;
937  }
938  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
939  return $cache->getWithSetCallback(
940  $cache->makeGlobalKey(
941  'resourceloader-jsparse',
942  self::$parseCacheVersion,
943  md5( $contents ),
944  $fileName
945  ),
946  $cache::TTL_WEEK,
947  function () use ( $contents, $fileName ) {
948  $parser = self::javaScriptParser();
949  $err = null;
950  try {
951  AtEase::suppressWarnings();
952  $parser->parse( $contents, $fileName, 1 );
953  } catch ( Exception $e ) {
954  $err = $e;
955  } finally {
956  AtEase::restoreWarnings();
957  }
958  if ( $err ) {
959  // Send the error to the browser console client-side.
960  // By returning this as replacement for the actual script,
961  // we ensure modules are safe to load in a batch request,
962  // without causing other unrelated modules to break.
963  return 'mw.log.error(' .
964  Xml::encodeJsVar( 'JavaScript parse error: ' . $err->getMessage() ) .
965  ');';
966  }
967  return $contents;
968  }
969  );
970  }
971 
975  protected static function javaScriptParser() {
976  if ( !self::$jsParser ) {
977  self::$jsParser = new JSParser();
978  }
979  return self::$jsParser;
980  }
981 
989  protected static function safeFilemtime( $filePath ) {
990  AtEase::suppressWarnings();
991  $mtime = filemtime( $filePath ) ?: 1;
992  AtEase::restoreWarnings();
993  return $mtime;
994  }
995 
1004  protected static function safeFileHash( $filePath ) {
1005  return FileContentsHasher::getFileContentsHash( $filePath );
1006  }
1007 
1015  public static function getVary( ResourceLoaderContext $context ) {
1016  return implode( '|', [
1017  $context->getSkin(),
1018  $context->getLanguage(),
1019  ] );
1020  }
1021 }
$resourceLoader
Definition: load.php:44
static filter( $filter, $data, array $options=[])
Run JavaScript or CSS data through a filter, caching the filtered result for future calls...
getMessages()
Get the messages needed for this module.
$context
Definition: load.php:45
static safeFileHash( $filePath)
Compute a non-cryptographic string hash of a file&#39;s contents.
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
$IP
Definition: WebStart.php:41
static getFileContentsHash( $filePaths, $algo='md4')
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
if(!isset( $args[0])) $lang
buildContent(ResourceLoaderContext $context)
Bundle all resources attached to this module into an array.
getMessageBlob(ResourceLoaderContext $context)
Get the hash of the message blob.
getStyleURLsForDebug(ResourceLoaderContext $context)
Get the URL or URLs to load for this module&#39;s CSS in debug mode.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
setFileDependencies(ResourceLoaderContext $context, $files)
Set in-object cache for file dependencies.
getPreloadLinks(ResourceLoaderContext $context)
Get a list of resources that web browsers may preload.
string bool $deprecated
Deprecation string or true if deprecated; false otherwise.
string [] $targets
What client platforms the module targets (e.g.
static getLocalClusterInstance()
Get the main cluster-local cache object.
static encodeJsVar( $value, $pretty=false)
Encode a variable of arbitrary type to JavaScript.
Definition: Xml.php:659
array $versionHash
Map of (context hash => cached module version hash)
static makeCombinedStyles(array $stylePairs)
Combines an associative array mapping media type to CSS into a single stylesheet with "@media" blocks...
const DB_MASTER
Definition: defines.php:26
setMessageBlob( $blob, $lang)
Set in-object cache for message blobs.
array $fileDeps
Map of (variant => indirect file dependencies)
A mutable version of ResourceLoaderContext.
getLessVars(ResourceLoaderContext $context)
Get module-specific LESS variables, if any.
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
getFileDependencies(ResourceLoaderContext $context)
Get the files this module depends on indirectly for a given skin.
getVersionHash(ResourceLoaderContext $context)
Get a string identifying the current version of this module in a given context.
static makeHash( $value)
Create a hash for module versioning purposes.
Interface for configuration instances.
Definition: Config.php:28
getSkipFunction()
Get the skip function.
getSource()
Get the source of this module.
array $contents
Map of (context hash => cached module content)
getTemplates()
Takes named templates by the module and returns an array mapping.
enableModuleContentVersion()
Whether to generate version hash based on module content.
saveFileDependencies(ResourceLoaderContext $context, array $localFileRefs)
Set the files this module depends on indirectly for a given skin.
$cache
Definition: mcc.php:33
array $msgBlobs
Map of (language => in-object cache for message blob)
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode...
static getRelativePaths(array $filePaths)
Make file paths relative to MediaWiki directory.
getTargets()
Get target(s) for the module, eg [&#39;desktop&#39;] or [&#39;desktop&#39;, &#39;mobile&#39;].
int $origin
Script and style modules form a hierarchy of trustworthiness, with core modules like skins and jQuery...
setName( $name)
Set this module&#39;s name.
static JSParser $jsParser
Lazy-initialized; use self::javaScriptParser()
validateScriptFile( $fileName, $contents)
Validate a given script file; if valid returns the original source.
shouldEmbedModule(ResourceLoaderContext $context)
Check whether this module should be embeded rather than linked.
getDefinitionSummary(ResourceLoaderContext $context)
Get the definition summary for this module.
getType()
Get the module&#39;s load type.
isKnownEmpty(ResourceLoaderContext $context)
Check whether this module is known to be empty.
getScript(ResourceLoaderContext $context)
Get all JS for this module for a given language and skin.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not...
getHeaders(ResourceLoaderContext $context)
Get headers to send as part of a module web response.
string null $name
Module name.
getGroup()
Get the group this module is in.
getStyles(ResourceLoaderContext $context)
Get all CSS for this module for a given skin.
getHash()
All factors that uniquely identify this request, except &#39;modules&#39;.
const DB_REPLICA
Definition: defines.php:25
static safeFilemtime( $filePath)
Safe version of filemtime(), which doesn&#39;t throw a PHP warning if the file doesn&#39;t exist...
$content
Definition: router.php:78
static getVary(ResourceLoaderContext $context)
Get vary string.
getDependencies(ResourceLoaderContext $context=null)
Get a list of modules this module depends on.
getDeprecationInformation(ResourceLoaderContext $context)
Get JS representing deprecation information for the current module if available.
setLogger(LoggerInterface $logger)
supportsURLLoading()
Whether this module supports URL loading.
getOrigin()
Get this module&#39;s origin.
getScriptURLsForDebug(ResourceLoaderContext $context)
Get the URL or URLs to load for this module&#39;s JS in debug mode.
getModuleContent(ResourceLoaderContext $context)
Get an array of this module&#39;s resources.
getName()
Get this module&#39;s name.
getFlip(ResourceLoaderContext $context)
Context object that contains information about the state of a specific ResourceLoader web request...