MediaWiki  master
ResourceLoader.php
Go to the documentation of this file.
1 <?php
23 namespace MediaWiki\ResourceLoader;
24 
25 use BagOStuff;
26 use Config;
27 use DeferredUpdates;
28 use Exception;
30 use HashBagOStuff;
31 use HttpStatus;
32 use InvalidArgumentException;
33 use Less_Environment;
34 use Less_Parser;
47 use Net_URL2;
48 use ObjectCache;
49 use OutputPage;
50 use Psr\Log\LoggerAwareInterface;
51 use Psr\Log\LoggerInterface;
52 use Psr\Log\NullLogger;
54 use RuntimeException;
55 use stdClass;
56 use Throwable;
57 use UnexpectedValueException;
58 use WebRequest;
61 use Wikimedia\Minify\CSSMin;
62 use Wikimedia\Minify\JavaScriptMinifier;
64 use Wikimedia\RequestTimeout\TimeoutException;
65 use Wikimedia\ScopedCallback;
66 use Wikimedia\Timestamp\ConvertibleTimestamp;
67 use Wikimedia\WrappedString;
68 use Xml;
69 use XmlJsCode;
70 
91 class ResourceLoader implements LoggerAwareInterface {
93  public const CACHE_VERSION = 9;
95  public const FILTER_NOMIN = '/*@nomin*/';
96 
98  private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
100  private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
102  private const MAXAGE_RECOVER = 60;
103 
105  protected static $debugMode = null;
106 
108  private $config;
110  private $blobStore;
112  private $depStore;
114  private $logger;
116  private $hookContainer;
118  private $hookRunner;
120  private $loadScript;
122  private $maxageVersioned;
124  private $maxageUnversioned;
126  private $useFileCache;
127 
129  private $modules = [];
131  private $moduleInfos = [];
133  private $testModuleNames = [];
135  private $sources = [];
137  protected $errors = [];
142  protected $extraHeaders = [];
144  private $depStoreUpdateBuffer = [];
149  private $moduleSkinStyles = [];
150 
174  public function __construct(
175  Config $config,
176  LoggerInterface $logger = null,
177  DependencyStore $tracker = null,
178  array $params = []
179  ) {
180  $this->loadScript = $params['loadScript'] ?? '/load.php';
181  $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
182  $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
183  $this->useFileCache = $params['useFileCache'] ?? false;
184 
185  $this->config = $config;
186  $this->logger = $logger ?: new NullLogger();
187 
188  $services = MediaWikiServices::getInstance();
189  $this->hookContainer = $services->getHookContainer();
190  $this->hookRunner = new HookRunner( $this->hookContainer );
191 
192  // Add 'local' source first
193  $this->addSource( 'local', $this->loadScript );
194 
195  // Special module that always exists
196  $this->register( 'startup', [ 'class' => StartUpModule::class ] );
197 
198  $this->setMessageBlobStore(
199  new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
200  );
201 
203  $this->setDependencyStore( $tracker );
204  }
205 
209  public function getConfig() {
210  return $this->config;
211  }
212 
217  public function setLogger( LoggerInterface $logger ) {
218  $this->logger = $logger;
219  }
220 
225  public function getLogger() {
226  return $this->logger;
227  }
228 
233  public function getMessageBlobStore() {
234  return $this->blobStore;
235  }
236 
241  public function setMessageBlobStore( MessageBlobStore $blobStore ) {
242  $this->blobStore = $blobStore;
243  }
244 
250  $this->depStore = $tracker;
251  }
252 
257  public function setModuleSkinStyles( array $moduleSkinStyles ) {
258  $this->moduleSkinStyles = $moduleSkinStyles;
259  }
260 
272  public function register( $name, array $info = null ) {
273  // Allow multiple modules to be registered in one call
274  $registrations = is_array( $name ) ? $name : [ $name => $info ];
275  foreach ( $registrations as $name => $info ) {
276  // Warn on duplicate registrations
277  if ( isset( $this->moduleInfos[$name] ) ) {
278  // A module has already been registered by this name
279  $this->logger->warning(
280  'ResourceLoader duplicate registration warning. ' .
281  'Another module has already been registered as ' . $name
282  );
283  }
284 
285  // Check validity
286  if ( !self::isValidModuleName( $name ) ) {
287  throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
288  . "see ResourceLoader::isValidModuleName()" );
289  }
290  if ( !is_array( $info ) ) {
291  throw new InvalidArgumentException(
292  'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
293  );
294  }
295 
296  // Attach module
297  $this->moduleInfos[$name] = $info;
298  }
299  }
300 
305  public function registerTestModules(): void {
306  $extRegistry = ExtensionRegistry::getInstance();
307  $testModules = $extRegistry->getAttribute( 'QUnitTestModules' );
308 
309  $testModuleNames = [];
310  foreach ( $testModules as $name => &$module ) {
311  // Turn any single-module dependency into an array
312  if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
313  $module['dependencies'] = [ $module['dependencies'] ];
314  }
315 
316  // Ensure the testrunner loads before any tests
317  $module['dependencies'][] = 'mediawiki.qunit-testrunner';
318 
319  // Keep track of the modules to load on SpecialJavaScriptTest
320  $testModuleNames[] = $name;
321  }
322 
323  // Core test modules (their names have further precedence).
324  $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
325  $testModuleNames[] = 'test.MediaWiki';
326 
327  $this->register( $testModules );
328  $this->testModuleNames = $testModuleNames;
329  }
330 
341  public function addSource( $sources, $loadUrl = null ) {
342  if ( !is_array( $sources ) ) {
343  $sources = [ $sources => $loadUrl ];
344  }
345  foreach ( $sources as $id => $source ) {
346  // Disallow duplicates
347  if ( isset( $this->sources[$id] ) ) {
348  throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
349  }
350 
351  // Support: MediaWiki 1.24 and earlier
352  if ( is_array( $source ) ) {
353  if ( !isset( $source['loadScript'] ) ) {
354  throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
355  }
356  $source = $source['loadScript'];
357  }
358 
359  $this->sources[$id] = $source;
360  }
361  }
362 
366  public function getModuleNames() {
367  return array_keys( $this->moduleInfos );
368  }
369 
377  public function getTestSuiteModuleNames() {
378  return $this->testModuleNames;
379  }
380 
388  public function isModuleRegistered( $name ) {
389  return isset( $this->moduleInfos[$name] );
390  }
391 
403  public function getModule( $name ) {
404  if ( !isset( $this->modules[$name] ) ) {
405  if ( !isset( $this->moduleInfos[$name] ) ) {
406  // No such module
407  return null;
408  }
409  // Construct the requested module object
410  $info = $this->moduleInfos[$name];
411  if ( isset( $info['factory'] ) ) {
413  $object = call_user_func( $info['factory'], $info );
414  } else {
415  $class = $info['class'] ?? FileModule::class;
417  $object = new $class( $info );
418  }
419  $object->setConfig( $this->getConfig() );
420  $object->setLogger( $this->logger );
421  $object->setHookContainer( $this->hookContainer );
422  $object->setName( $name );
423  $object->setDependencyAccessCallbacks(
424  [ $this, 'loadModuleDependenciesInternal' ],
425  [ $this, 'saveModuleDependenciesInternal' ]
426  );
427  $object->setSkinStylesOverride( $this->moduleSkinStyles );
428  $this->modules[$name] = $object;
429  }
430 
431  return $this->modules[$name];
432  }
433 
440  public function preloadModuleInfo( array $moduleNames, Context $context ) {
441  // Load all tracked indirect file dependencies for the modules
442  $vary = Module::getVary( $context );
443  $entitiesByModule = [];
444  foreach ( $moduleNames as $moduleName ) {
445  $entitiesByModule[$moduleName] = "$moduleName|$vary";
446  }
447  $depsByEntity = $this->depStore->retrieveMulti(
448  self::RL_DEP_STORE_PREFIX,
449  $entitiesByModule
450  );
451  // Inject the indirect file dependencies for all the modules
452  foreach ( $moduleNames as $moduleName ) {
453  $module = $this->getModule( $moduleName );
454  if ( $module ) {
455  $entity = $entitiesByModule[$moduleName];
456  $deps = $depsByEntity[$entity];
457  $paths = Module::expandRelativePaths( $deps['paths'] );
458  $module->setFileDependencies( $context, $paths );
459  }
460  }
461 
462  // Batched version of WikiModule::getTitleInfo
463  $dbr = wfGetDB( DB_REPLICA );
464  WikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
465 
466  // Prime in-object cache for message blobs for modules with messages
467  $modulesWithMessages = [];
468  foreach ( $moduleNames as $moduleName ) {
469  $module = $this->getModule( $moduleName );
470  if ( $module && $module->getMessages() ) {
471  $modulesWithMessages[$moduleName] = $module;
472  }
473  }
474  // Prime in-object cache for message blobs for modules with messages
475  $lang = $context->getLanguage();
476  $store = $this->getMessageBlobStore();
477  $blobs = $store->getBlobs( $modulesWithMessages, $lang );
478  foreach ( $blobs as $moduleName => $blob ) {
479  $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
480  }
481  }
482 
489  public function loadModuleDependenciesInternal( $moduleName, $variant ) {
490  $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
491 
492  return Module::expandRelativePaths( $deps['paths'] );
493  }
494 
502  public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
503  $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
504  $entity = "$moduleName|$variant";
505 
506  if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
507  // Dependency store needs to be updated with the new path list
508  if ( $paths ) {
509  $deps = $this->depStore->newEntityDependencies( $paths, time() );
510  $this->depStoreUpdateBuffer[$entity] = $deps;
511  } else {
512  $this->depStoreUpdateBuffer[$entity] = null;
513  }
514  }
515 
516  // If paths were unchanged, leave the dependency store unchanged also.
517  // The entry will eventually expire, after which we will briefly issue an incomplete
518  // version hash for a 5-min startup window, the module then recomputes and rediscovers
519  // the paths and arrive at the same module version hash once again. It will churn
520  // part of the browser cache once, for clients connecting during that window.
521 
522  if ( !$hasPendingUpdate ) {
524  $updatesByEntity = $this->depStoreUpdateBuffer;
525  $this->depStoreUpdateBuffer = [];
527 
528  $scopeLocks = [];
529  $depsByEntity = [];
530  $entitiesUnreg = [];
531  foreach ( $updatesByEntity as $entity => $update ) {
532  $lockKey = $cache->makeKey( 'rl-deps', $entity );
533  $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
534  if ( !$scopeLocks[$entity] ) {
535  // avoid duplicate write request slams (T124649)
536  // the lock must be specific to the current wiki (T247028)
537  continue;
538  }
539  if ( $update === null ) {
540  $entitiesUnreg[] = $entity;
541  } else {
542  $depsByEntity[$entity] = $update;
543  }
544  }
545 
546  $ttl = self::RL_MODULE_DEP_TTL;
547  $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
548  $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
549  } );
550  }
551  }
552 
558  public function getSources() {
559  return $this->sources;
560  }
561 
570  public function getLoadScript( $source ) {
571  if ( !isset( $this->sources[$source] ) ) {
572  throw new UnexpectedValueException( "Unknown source '$source'" );
573  }
574  return $this->sources[$source];
575  }
576 
580  public const HASH_LENGTH = 5;
581 
644  public static function makeHash( $value ) {
645  $hash = hash( 'fnv132', $value );
646  // The base_convert will pad it (if too short),
647  // then substr() will trim it (if too long).
648  return substr(
649  \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
650  0,
651  self::HASH_LENGTH
652  );
653  }
654 
664  public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
666  $this->logger->warning(
667  $msg,
668  $context + [ 'exception' => $e ]
669  );
670  $this->errors[] = self::formatExceptionNoComment( $e );
671  }
672 
681  public function getCombinedVersion( Context $context, array $moduleNames ) {
682  if ( !$moduleNames ) {
683  return '';
684  }
685  $hashes = array_map( function ( $module ) use ( $context ) {
686  try {
687  return $this->getModule( $module )->getVersionHash( $context );
688  } catch ( TimeoutException $e ) {
689  throw $e;
690  } catch ( Exception $e ) {
691  // If modules fail to compute a version, don't fail the request (T152266)
692  // and still compute versions of other modules.
693  $this->outputErrorAndLog( $e,
694  'Calculating version for "{module}" failed: {exception}',
695  [
696  'module' => $module,
697  ]
698  );
699  return '';
700  }
701  }, $moduleNames );
702  return self::makeHash( implode( '', $hashes ) );
703  }
704 
719  public function makeVersionQuery( Context $context, array $modules ) {
720  // As of MediaWiki 1.28, the server and client use the same algorithm for combining
721  // version hashes. There is no technical reason for this to be same, and for years the
722  // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
723  // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
724  // query parameter), then this method must continue to match the JS one.
725  $filtered = [];
726  foreach ( $modules as $name ) {
727  if ( !$this->getModule( $name ) ) {
728  // If a versioned request contains a missing module, the version is a mismatch
729  // as the client considered a module (and version) we don't have.
730  return '';
731  }
732  $filtered[] = $name;
733  }
734  return $this->getCombinedVersion( $context, $filtered );
735  }
736 
742  public function respond( Context $context ) {
743  // Buffer output to catch warnings. Normally we'd use ob_clean() on the
744  // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
745  // is used: ob_clean() will clear the GZIP header in that case and it won't come
746  // back for subsequent output, resulting in invalid GZIP. So we have to wrap
747  // the whole thing in our own output buffer to be sure the active buffer
748  // doesn't use ob_gzhandler.
749  // See https://bugs.php.net/bug.php?id=36514
750  ob_start();
751 
752  $this->errors = [];
753  $responseTime = $this->measureResponseTime();
754  ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' );
755 
756  // Find out which modules are missing and instantiate the others
757  $modules = [];
758  $missing = [];
759  foreach ( $context->getModules() as $name ) {
760  $module = $this->getModule( $name );
761  if ( $module ) {
762  // Do not allow private modules to be loaded from the web.
763  // This is a security issue, see T36907.
764  if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
765  // Not a serious error, just means something is trying to access it (T101806)
766  $this->logger->debug( "Request for private module '$name' denied" );
767  $this->errors[] = "Cannot build private module \"$name\"";
768  continue;
769  }
770  $modules[$name] = $module;
771  } else {
772  $missing[] = $name;
773  }
774  }
775 
776  try {
777  // Preload for getCombinedVersion() and for batch makeModuleResponse()
778  $this->preloadModuleInfo( array_keys( $modules ), $context );
779  } catch ( TimeoutException $e ) {
780  throw $e;
781  } catch ( Exception $e ) {
782  $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
783  }
784 
785  // Combine versions to propagate cache invalidation
786  $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
787 
788  // See RFC 2616 § 3.11 Entity Tags
789  // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
790  $etag = 'W/"' . $versionHash . '"';
791 
792  // Try the client-side cache first
793  if ( $this->tryRespondNotModified( $context, $etag ) ) {
794  return; // output handled (buffers cleared)
795  }
796 
797  // Use file cache if enabled and available...
798  if ( $this->useFileCache ) {
799  $fileCache = ResourceFileCache::newFromContext( $context );
800  if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
801  return; // output handled
802  }
803  } else {
804  $fileCache = null;
805  }
806 
807  // Generate a response
808  $response = $this->makeModuleResponse( $context, $modules, $missing );
809 
810  // Capture any PHP warnings from the output buffer and append them to the
811  // error list if we're in debug mode.
812  if ( $context->getDebug() ) {
813  $warnings = ob_get_contents();
814  if ( strlen( $warnings ) ) {
815  $this->errors[] = $warnings;
816  }
817  }
818 
819  // Consider saving the response to file cache (unless there are errors).
820  if ( $fileCache && !$this->errors && $missing === [] &&
821  ResourceFileCache::useFileCache( $context ) ) {
822  if ( $fileCache->isCacheWorthy() ) {
823  // There were enough hits, save the response to the cache
824  $fileCache->saveText( $response );
825  } else {
826  $fileCache->incrMissesRecent( $context->getRequest() );
827  }
828  }
829 
830  $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
831 
832  // Remove the output buffer and output the response
833  ob_end_clean();
834 
835  if ( $context->getImageObj() && $this->errors ) {
836  // We can't show both the error messages and the response when it's an image.
837  $response = implode( "\n\n", $this->errors );
838  } elseif ( $this->errors ) {
839  $errorText = implode( "\n\n", $this->errors );
840  $errorResponse = self::makeComment( $errorText );
841  if ( $context->shouldIncludeScripts() ) {
842  $errorResponse .= 'if (window.console && console.error) { console.error('
843  . $context->encodeJson( $errorText )
844  . "); }\n";
845  }
846 
847  // Prepend error info to the response
848  $response = $errorResponse . $response;
849  }
850 
851  // @phan-suppress-next-line SecurityCheck-XSS
852  echo $response;
853  }
854 
859  protected function measureResponseTime() {
860  $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
861  return new ScopedCallback( static function () use ( $statStart ) {
862  $statTiming = microtime( true ) - $statStart;
863  $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
864  $stats->timing( 'resourceloader.responseTime', $statTiming * 1000 );
865  } );
866  }
867 
878  protected function sendResponseHeaders(
879  Context $context, $etag, $errors, array $extra = []
880  ): void {
881  HeaderCallback::warnIfHeadersSent();
882 
883  if ( $errors ) {
884  $maxage = self::MAXAGE_RECOVER;
885  } elseif (
886  $context->getVersion() !== null
887  && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
888  ) {
889  // If we need to self-correct, set a very short cache expiry
890  // to basically just debounce CDN traffic. This applies to:
891  // - Internal errors, e.g. due to misconfiguration.
892  // - Version mismatch, e.g. due to deployment race (T117587, T47877).
893  $this->logger->info( 'Client and server registry version out of sync' );
894  $maxage = self::MAXAGE_RECOVER;
895  } elseif ( $context->getVersion() === null ) {
896  // Resources that can't set a version, should have their updates propagate to
897  // clients quickly. This applies to shared resources linked from HTML, such as
898  // the startup module and stylesheets.
899  $maxage = $this->maxageUnversioned;
900  } else {
901  // When a version is set, use a long expiry because changes
902  // will naturally miss the cache by using a different URL.
903  $maxage = $this->maxageVersioned;
904  }
905  if ( $context->getImageObj() ) {
906  // Output different headers if we're outputting textual errors.
907  if ( $errors ) {
908  header( 'Content-Type: text/plain; charset=utf-8' );
909  } else {
910  $context->getImageObj()->sendResponseHeaders( $context );
911  }
912  } elseif ( $context->getOnly() === 'styles' ) {
913  header( 'Content-Type: text/css; charset=utf-8' );
914  header( 'Access-Control-Allow-Origin: *' );
915  } else {
916  header( 'Content-Type: text/javascript; charset=utf-8' );
917  }
918  // See RFC 2616 § 14.19 ETag
919  // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
920  header( 'ETag: ' . $etag );
921  if ( $context->getDebug() ) {
922  // Do not cache debug responses
923  header( 'Cache-Control: private, no-cache, must-revalidate' );
924  header( 'Pragma: no-cache' );
925  } else {
926  // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
927  // the background instead of blocking the next page load (eg. startup module, or CSS).
928  $staleDirective = ( $maxage > self::MAXAGE_RECOVER
929  ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
930  : ''
931  );
932  header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
933  header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
934  }
935  foreach ( $extra as $header ) {
936  header( $header );
937  }
938  }
939 
950  protected function tryRespondNotModified( Context $context, $etag ) {
951  // See RFC 2616 § 14.26 If-None-Match
952  // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
953  $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
954  // Never send 304s in debug mode
955  if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
956  // There's another bug in ob_gzhandler (see also the comment at
957  // the top of this function) that causes it to gzip even empty
958  // responses, meaning it's impossible to produce a truly empty
959  // response (because the gzip header is always there). This is
960  // a problem because 304 responses have to be completely empty
961  // per the HTTP spec, and Firefox behaves buggily when they're not.
962  // See also https://bugs.php.net/bug.php?id=51579
963  // To work around this, we tear down all output buffering before
964  // sending the 304.
965  wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
966 
967  HttpStatus::header( 304 );
968 
969  $this->sendResponseHeaders( $context, $etag, false );
970  return true;
971  }
972  return false;
973  }
974 
983  protected function tryRespondFromFileCache(
984  ResourceFileCache $fileCache,
985  Context $context,
986  $etag
987  ) {
988  // Buffer output to catch warnings.
989  ob_start();
990  // Get the maximum age the cache can be
991  $maxage = $context->getVersion() === null
992  ? $this->maxageUnversioned
993  : $this->maxageVersioned;
994  // Minimum timestamp the cache file must have
995  $minTime = time() - $maxage;
996  $good = $fileCache->isCacheGood( ConvertibleTimestamp::convert( TS_MW, $minTime ) );
997  if ( !$good ) {
998  try { // RL always hits the DB on file cache miss...
999  wfGetDB( DB_REPLICA );
1000  } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
1001  $good = $fileCache->isCacheGood(); // cache existence check
1002  }
1003  }
1004  if ( $good ) {
1005  $ts = $fileCache->cacheTimestamp();
1006  // Send content type and cache headers
1007  $this->sendResponseHeaders( $context, $etag, false );
1008  $response = $fileCache->fetchText();
1009  // Capture any PHP warnings from the output buffer and append them to the
1010  // response in a comment if we're in debug mode.
1011  if ( $context->getDebug() ) {
1012  $warnings = ob_get_contents();
1013  if ( strlen( $warnings ) ) {
1014  $response = self::makeComment( $warnings ) . $response;
1015  }
1016  }
1017  // Remove the output buffer and output the response
1018  ob_end_clean();
1019  echo $response . "\n/* Cached {$ts} */";
1020  return true; // cache hit
1021  }
1022  // Clear buffer
1023  ob_end_clean();
1024 
1025  return false; // cache miss
1026  }
1027 
1036  public static function makeComment( $text ) {
1037  $encText = str_replace( '*/', '* /', $text );
1038  return "/*\n$encText\n*/\n";
1039  }
1040 
1047  public static function formatException( Throwable $e ) {
1048  return self::makeComment( self::formatExceptionNoComment( $e ) );
1049  }
1050 
1058  protected static function formatExceptionNoComment( Throwable $e ) {
1061  }
1062 
1063  return MWExceptionHandler::getLogMessage( $e ) .
1064  "\nBacktrace:\n" .
1066  }
1067 
1079  public function makeModuleResponse( Context $context,
1080  array $modules, array $missing = []
1081  ) {
1082  if ( $modules === [] && $missing === [] ) {
1083  return <<<MESSAGE
1084 /* This file is the Web entry point for MediaWiki's ResourceLoader:
1085  <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1086  no modules were requested. Max made me put this here. */
1087 MESSAGE;
1088  }
1089 
1090  $image = $context->getImageObj();
1091  if ( $image ) {
1092  $data = $image->getImageData( $context );
1093  if ( $data === false ) {
1094  $data = '';
1095  $this->errors[] = 'Image generation failed';
1096  }
1097  return $data;
1098  }
1099 
1100  $states = [];
1101  foreach ( $missing as $name ) {
1102  $states[$name] = 'missing';
1103  }
1104 
1105  $only = $context->getOnly();
1106  $filter = $only === 'styles' ? 'minify-css' : 'minify-js';
1107  $debug = (bool)$context->getDebug();
1108 
1109  $out = '';
1110  foreach ( $modules as $name => $module ) {
1111  try {
1112  $content = $module->getModuleContent( $context );
1113  $implementKey = $name . '@' . $module->getVersionHash( $context );
1114  $strContent = '';
1115 
1116  if ( isset( $content['headers'] ) ) {
1117  $this->extraHeaders = array_merge( $this->extraHeaders, $content['headers'] );
1118  }
1119 
1120  // Append output
1121  switch ( $only ) {
1122  case 'scripts':
1123  $scripts = $content['scripts'];
1124  if ( is_string( $scripts ) ) {
1125  // Load scripts raw...
1126  $strContent = $scripts;
1127  } elseif ( is_array( $scripts ) ) {
1128  // ...except when $scripts is an array of URLs or an associative array
1129  $strContent = self::makeLoaderImplementScript(
1130  $implementKey,
1131  $scripts,
1132  [],
1133  null,
1134  []
1135  );
1136  }
1137  break;
1138  case 'styles':
1139  $styles = $content['styles'];
1140  // We no longer separate into media, they are all combined now with
1141  // custom media type groups into @media .. {} sections as part of the css string.
1142  // Module returns either an empty array or a numerical array with css strings.
1143  $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
1144  break;
1145  default:
1146  $scripts = $content['scripts'] ?? '';
1147  if ( is_string( $scripts ) ) {
1148  if ( $name === 'site' || $name === 'user' ) {
1149  // Legacy scripts that run in the global scope without a closure.
1150  // mw.loader.implement will use eval if scripts is a string.
1151  // Minify manually here, because general response minification is
1152  // not effective due it being a string literal, not a function.
1153  if ( !$debug ) {
1154  $scripts = self::filter( 'minify-js', $scripts ); // T107377
1155  }
1156  } else {
1157  $scripts = new XmlJsCode( $scripts );
1158  }
1159  }
1160  $strContent = self::makeLoaderImplementScript(
1161  $implementKey,
1162  $scripts,
1163  $content['styles'] ?? [],
1164  isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : null,
1165  $content['templates'] ?? []
1166  );
1167  break;
1168  }
1169 
1170  if ( $debug ) {
1171  // In debug mode, separate each response by a new line.
1172  // For example, between 'mw.loader.implement();' statements.
1173  $strContent = self::ensureNewline( $strContent );
1174  } else {
1175  $strContent = self::filter( $filter, $strContent, [
1176  // Important: Do not cache minifications of embedded modules
1177  // This is especially for the private 'user.options' module,
1178  // which varies on every pageview and would explode the cache (T84960)
1179  'cache' => !$module->shouldEmbedModule( $context )
1180  ] );
1181  }
1182 
1183  if ( $only === 'scripts' ) {
1184  // Use a linebreak between module scripts (T162719)
1185  $out .= self::ensureNewline( $strContent );
1186  } else {
1187  $out .= $strContent;
1188  }
1189  } catch ( TimeoutException $e ) {
1190  throw $e;
1191  } catch ( Exception $e ) {
1192  $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1193 
1194  // Respond to client with error-state instead of module implementation
1195  $states[$name] = 'error';
1196  unset( $modules[$name] );
1197  }
1198  }
1199 
1200  // Update module states
1201  if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1202  if ( $modules && $only === 'scripts' ) {
1203  // Set the state of modules loaded as only scripts to ready as
1204  // they don't have an mw.loader.implement wrapper that sets the state
1205  foreach ( $modules as $name => $module ) {
1206  $states[$name] = 'ready';
1207  }
1208  }
1209 
1210  // Set the state of modules we didn't respond to with mw.loader.implement
1211  if ( $states ) {
1212  $stateScript = self::makeLoaderStateScript( $context, $states );
1213  if ( !$debug ) {
1214  $stateScript = self::filter( 'minify-js', $stateScript );
1215  }
1216  // Use a linebreak between module script and state script (T162719)
1217  $out = self::ensureNewline( $out ) . $stateScript;
1218  }
1219  } elseif ( $states ) {
1220  $this->errors[] = 'Problematic modules: '
1221  // Silently ignore invalid UTF-8 injected via 'modules' query
1222  // Don't issue server-side warnings for client errors. (T331641)
1223  // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1224  . @$context->encodeJson( $states );
1225  }
1226 
1227  return $out;
1228  }
1229 
1236  public static function ensureNewline( $str ) {
1237  $end = substr( $str, -1 );
1238  if ( $end === false || $end === '' || $end === "\n" ) {
1239  return $str;
1240  }
1241  return $str . "\n";
1242  }
1243 
1250  public function getModulesByMessage( $messageKey ) {
1251  $moduleNames = [];
1252  foreach ( $this->getModuleNames() as $moduleName ) {
1253  $module = $this->getModule( $moduleName );
1254  if ( in_array( $messageKey, $module->getMessages() ) ) {
1255  $moduleNames[] = $moduleName;
1256  }
1257  }
1258  return $moduleNames;
1259  }
1260 
1280  private static function makeLoaderImplementScript(
1281  $name, $scripts, $styles, $messages, $templates
1282  ) {
1283  if ( $scripts instanceof XmlJsCode ) {
1284  if ( $scripts->value === '' ) {
1285  $scripts = null;
1286  } else {
1287  $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
1288  }
1289  } elseif ( is_array( $scripts ) && isset( $scripts['files'] ) ) {
1290  $files = $scripts['files'];
1291  foreach ( $files as &$file ) {
1292  // $file is changed (by reference) from a descriptor array to the content of the file
1293  // All of these essentially do $file = $file['content'];, some just have wrapping around it
1294  if ( $file['type'] === 'script' ) {
1295  // Ensure that the script has a newline at the end to close any comment in the
1296  // last line.
1297  $content = self::ensureNewline( $file['content'] );
1298  // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1299  // $/jQuery are simply used as globals instead.
1300  // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1301  $file = new XmlJsCode( "function ( require, module, exports ) {\n$content}" );
1302  } else {
1303  $file = $file['content'];
1304  }
1305  }
1306  $scripts = XmlJsCode::encodeObject( [
1307  'main' => $scripts['main'],
1308  'files' => XmlJsCode::encodeObject( $files, true )
1309  ], true );
1310  } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
1311  throw new InvalidArgumentException( 'Script must be a string or an array of URLs' );
1312  }
1313 
1314  // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
1315  // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1316  // of "{}". Force them to objects.
1317  $module = [
1318  $name,
1319  $scripts,
1320  (object)$styles,
1321  $messages ?? (object)[],
1322  (object)$templates
1323  ];
1324  self::trimArray( $module );
1325 
1326  // We use pretty output unconditionally to make this method simpler.
1327  // Minification is taken care of closer to the output.
1328  return Xml::encodeJsCall( 'mw.loader.implement', $module, true );
1329  }
1330 
1338  public static function makeCombinedStyles( array $stylePairs ) {
1339  $out = [];
1340  foreach ( $stylePairs as $media => $styles ) {
1341  // FileModule::getStyle can return the styles as a string or an
1342  // array of strings. This is to allow separation in the front-end.
1343  $styles = (array)$styles;
1344  foreach ( $styles as $style ) {
1345  $style = trim( $style );
1346  // Don't output an empty "@media print { }" block (T42498)
1347  if ( $style === '' ) {
1348  continue;
1349  }
1350  // Transform the media type based on request params and config
1351  // The way that this relies on $wgRequest to propagate request params is slightly evil
1352  $media = OutputPage::transformCssMedia( $media );
1353 
1354  if ( $media === '' || $media == 'all' ) {
1355  $out[] = $style;
1356  } elseif ( is_string( $media ) ) {
1357  $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1358  }
1359  // else: skip
1360  }
1361  }
1362  return $out;
1363  }
1364 
1372  private static function encodeJsonForScript( $data ) {
1373  // Keep output as small as possible by disabling needless escape modes
1374  // that PHP uses by default.
1375  // However, while most module scripts are only served on HTTP responses
1376  // for JavaScript, some modules can also be embedded in the HTML as inline
1377  // scripts. This, and the fact that we sometimes need to export strings
1378  // containing user-generated content and labels that may genuinely contain
1379  // a sequences like "</script>", we need to encode either '/' or '<'.
1380  // By default PHP escapes '/'. Let's escape '<' instead which is less common
1381  // and allows URLs to mostly remain readable.
1382  $jsonFlags = JSON_UNESCAPED_SLASHES |
1383  JSON_UNESCAPED_UNICODE |
1384  JSON_HEX_TAG |
1385  JSON_HEX_AMP;
1386  if ( self::inDebugMode() ) {
1387  $jsonFlags |= JSON_PRETTY_PRINT;
1388  }
1389  return json_encode( $data, $jsonFlags );
1390  }
1391 
1400  public static function makeLoaderStateScript(
1401  Context $context, array $states
1402  ) {
1403  return 'mw.loader.state('
1404  // Silently ignore invalid UTF-8 injected via 'modules' query
1405  // Don't issue server-side warnings for client errors. (T331641)
1406  // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1407  . @$context->encodeJson( $states )
1408  . ');';
1409  }
1410 
1411  private static function isEmptyObject( stdClass $obj ) {
1412  foreach ( $obj as $value ) {
1413  return false;
1414  }
1415  return true;
1416  }
1417 
1431  private static function trimArray( array &$array ): void {
1432  $i = count( $array );
1433  while ( $i-- ) {
1434  if ( $array[$i] === null
1435  || $array[$i] === []
1436  || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
1437  || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1438  ) {
1439  unset( $array[$i] );
1440  } else {
1441  break;
1442  }
1443  }
1444  }
1445 
1471  public static function makeLoaderRegisterScript(
1472  Context $context, array $modules
1473  ) {
1474  // Optimisation: Transform dependency names into indexes when possible
1475  // to produce smaller output. They are expanded by mw.loader.register on
1476  // the other end.
1477  $index = [];
1478  foreach ( $modules as $i => &$module ) {
1479  // Build module name index
1480  $index[$module[0]] = $i;
1481  }
1482  foreach ( $modules as &$module ) {
1483  if ( isset( $module[2] ) ) {
1484  foreach ( $module[2] as &$dependency ) {
1485  if ( isset( $index[$dependency] ) ) {
1486  // Replace module name in dependency list with index
1487  $dependency = $index[$dependency];
1488  }
1489  }
1490  }
1491  }
1492 
1493  array_walk( $modules, [ self::class, 'trimArray' ] );
1494 
1495  return 'mw.loader.register('
1496  . $context->encodeJson( $modules )
1497  . ');';
1498  }
1499 
1513  public static function makeLoaderSourcesScript(
1514  Context $context, array $sources
1515  ) {
1516  return 'mw.loader.addSource('
1517  . $context->encodeJson( $sources )
1518  . ');';
1519  }
1520 
1527  public static function makeLoaderConditionalScript( $script ) {
1528  // Adds a function to lazy-created RLQ
1529  return '(RLQ=window.RLQ||[]).push(function(){' .
1530  trim( $script ) . '});';
1531  }
1532 
1541  public static function makeInlineCodeWithModule( $modules, $script ) {
1542  // Adds an array to lazy-created RLQ
1543  return '(RLQ=window.RLQ||[]).push(['
1544  . self::encodeJsonForScript( $modules ) . ','
1545  . 'function(){' . trim( $script ) . '}'
1546  . ']);';
1547  }
1548 
1560  public static function makeInlineScript( $script, $nonce = null ) {
1561  $js = self::makeLoaderConditionalScript( $script );
1562  $escNonce = '';
1563  if ( $nonce === null ) {
1564  wfWarn( __METHOD__ . " did not get nonce. Will break CSP" );
1565  } elseif ( $nonce !== false ) {
1566  // If it was false, CSP is disabled, so no nonce attribute.
1567  // Nonce should be only base64 characters, so should be safe,
1568  // but better to be safely escaped than sorry.
1569  $escNonce = ' nonce="' . htmlspecialchars( $nonce ) . '"';
1570  }
1571 
1572  return new WrappedString(
1573  Html::inlineScript( $js, $nonce ),
1574  "<script$escNonce>(RLQ=window.RLQ||[]).push(function(){",
1575  '});</script>'
1576  );
1577  }
1578 
1587  public static function makeConfigSetScript( array $configuration ) {
1588  $json = self::encodeJsonForScript( $configuration );
1589  if ( $json === false ) {
1590  $e = new Exception(
1591  'JSON serialization of config data failed. ' .
1592  'This usually means the config data is not valid UTF-8.'
1593  );
1595  return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1596  }
1597  return "mw.config.set($json);";
1598  }
1599 
1613  public static function makePackedModulesString( array $modules ) {
1614  $moduleMap = []; // [ prefix => [ suffixes ] ]
1615  foreach ( $modules as $module ) {
1616  $pos = strrpos( $module, '.' );
1617  $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1618  $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1619  $moduleMap[$prefix][] = $suffix;
1620  }
1621 
1622  $arr = [];
1623  foreach ( $moduleMap as $prefix => $suffixes ) {
1624  $p = $prefix === '' ? '' : $prefix . '.';
1625  $arr[] = $p . implode( ',', $suffixes );
1626  }
1627  return implode( '|', $arr );
1628  }
1629 
1641  public static function expandModuleNames( $modules ) {
1642  $retval = [];
1643  $exploded = explode( '|', $modules );
1644  foreach ( $exploded as $group ) {
1645  if ( strpos( $group, ',' ) === false ) {
1646  // This is not a set of modules in foo.bar,baz notation
1647  // but a single module
1648  $retval[] = $group;
1649  continue;
1650  }
1651  // This is a set of modules in foo.bar,baz notation
1652  $pos = strrpos( $group, '.' );
1653  if ( $pos === false ) {
1654  // Prefixless modules, i.e. without dots
1655  $retval = array_merge( $retval, explode( ',', $group ) );
1656  continue;
1657  }
1658  // We have a prefix and a bunch of suffixes
1659  $prefix = substr( $group, 0, $pos ); // 'foo'
1660  $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1661  foreach ( $suffixes as $suffix ) {
1662  $retval[] = "$prefix.$suffix";
1663  }
1664  }
1665  return $retval;
1666  }
1667 
1678  public static function inDebugMode() {
1679  if ( self::$debugMode === null ) {
1680  global $wgRequest;
1681  $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1682  MainConfigNames::ResourceLoaderDebug );
1683  $str = $wgRequest->getRawVal( 'debug',
1684  $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' )
1685  );
1686  self::$debugMode = Context::debugFromString( $str );
1687  }
1688  return self::$debugMode;
1689  }
1690 
1701  public static function clearCache() {
1702  self::$debugMode = null;
1703  }
1704 
1714  public function createLoaderURL( $source, Context $context,
1715  array $extraQuery = []
1716  ) {
1717  $query = self::createLoaderQuery( $context, $extraQuery );
1718  $script = $this->getLoadScript( $source );
1719 
1720  return wfAppendQuery( $script, $query );
1721  }
1722 
1732  protected static function createLoaderQuery(
1733  Context $context, array $extraQuery = []
1734  ) {
1735  return self::makeLoaderQuery(
1736  $context->getModules(),
1737  $context->getLanguage(),
1738  $context->getSkin(),
1739  $context->getUser(),
1740  $context->getVersion(),
1741  $context->getDebug(),
1742  $context->getOnly(),
1743  $context->getRequest()->getBool( 'printable' ),
1744  null,
1745  $extraQuery
1746  );
1747  }
1748 
1765  public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1766  $version = null, $debug = Context::DEBUG_OFF, $only = null,
1767  $printable = false, $handheld = null, array $extraQuery = []
1768  ) {
1769  $query = [
1770  'modules' => self::makePackedModulesString( $modules ),
1771  ];
1772  // Keep urls short by omitting query parameters that
1773  // match the defaults assumed by Context.
1774  // Note: This relies on the defaults either being insignificant or forever constant,
1775  // as otherwise cached urls could change in meaning when the defaults change.
1776  if ( $lang !== Context::DEFAULT_LANG ) {
1777  $query['lang'] = $lang;
1778  }
1779  if ( $skin !== Context::DEFAULT_SKIN ) {
1780  $query['skin'] = $skin;
1781  }
1782  if ( $debug !== Context::DEBUG_OFF ) {
1783  $query['debug'] = strval( $debug );
1784  }
1785  if ( $user !== null ) {
1786  $query['user'] = $user;
1787  }
1788  if ( $version !== null ) {
1789  $query['version'] = $version;
1790  }
1791  if ( $only !== null ) {
1792  $query['only'] = $only;
1793  }
1794  if ( $printable ) {
1795  $query['printable'] = 1;
1796  }
1797  $query += $extraQuery;
1798 
1799  // Make queries uniform in order
1800  ksort( $query );
1801  return $query;
1802  }
1803 
1813  public static function isValidModuleName( $moduleName ) {
1814  $len = strlen( $moduleName );
1815  return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
1816  }
1817 
1828  public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
1829  global $IP;
1830  // When called from the installer, it is possible that a required PHP extension
1831  // is missing (at least for now; see T49564). If this is the case, throw an
1832  // exception (caught by the installer) to prevent a fatal error later on.
1833  if ( !class_exists( Less_Parser::class ) ) {
1834  throw new RuntimeException( 'MediaWiki requires the less.php parser' );
1835  }
1836 
1837  $importDirs[] = "$IP/resources/src/mediawiki.less";
1838 
1839  $parser = new Less_Parser;
1840  $parser->ModifyVars( $vars );
1841  $parser->SetOption( 'relativeUrls', false );
1842 
1843  // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
1844  $formattedImportDirs = array_fill_keys( $importDirs, '' );
1845  // Add a callback to the import dirs array for path remapping
1846  $formattedImportDirs[] = static function ( $path ) {
1847  global $IP;
1848  $importMap = [
1849  '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
1850  'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
1851  '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
1852  throw new RuntimeException(
1853  'Importing from @wikimedia/codex-design-tokens is not supported. ' .
1854  "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
1855  );
1856  }
1857  ];
1858  foreach ( $importMap as $importPath => $substPath ) {
1859  if ( str_starts_with( $path, $importPath ) ) {
1860  $restOfPath = substr( $path, strlen( $importPath ) );
1861  if ( is_callable( $substPath ) ) {
1862  $resolvedPath = call_user_func( $substPath, $restOfPath );
1863  } else {
1864  $filePath = $substPath . $restOfPath;
1865 
1866  $resolvedPath = null;
1867  if ( file_exists( $filePath ) ) {
1868  $resolvedPath = $filePath;
1869  } elseif ( file_exists( "$filePath.less" ) ) {
1870  $resolvedPath = "$filePath.less";
1871  }
1872  }
1873 
1874  if ( $resolvedPath !== null ) {
1875  return [
1876  Less_Environment::normalizePath( $resolvedPath ),
1877  Less_Environment::normalizePath( dirname( $path ) )
1878  ];
1879  } else {
1880  break;
1881  }
1882  }
1883  }
1884  return [ null, null ];
1885  };
1886  $parser->SetImportDirs( $formattedImportDirs );
1887 
1888  return $parser;
1889  }
1890 
1904  public function expandUrl( string $base, string $url ): string {
1905  // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
1906  $isProtoRelative = strpos( $base, '//' ) === 0;
1907  if ( $isProtoRelative ) {
1908  $base = "https:$base";
1909  }
1910  // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
1911  $baseUrl = new Net_URL2( $base );
1912  $ret = $baseUrl->resolve( $url );
1913  if ( $isProtoRelative ) {
1914  $ret->setScheme( false );
1915  }
1916  return $ret->getURL();
1917  }
1918 
1936  public static function filter( $filter, $data, array $options = [] ) {
1937  if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
1938  return $data;
1939  }
1940 
1941  if ( isset( $options['cache'] ) && $options['cache'] === false ) {
1942  return self::applyFilter( $filter, $data ) ?? $data;
1943  }
1944 
1945  $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
1947 
1948  $key = $cache->makeGlobalKey(
1949  'resourceloader-filter',
1950  $filter,
1951  self::CACHE_VERSION,
1952  md5( $data )
1953  );
1954 
1955  $incKey = "resourceloader_cache.$filter.hit";
1956  $result = $cache->getWithSetCallback(
1957  $key,
1958  BagOStuff::TTL_DAY,
1959  function () use ( $filter, $data, &$incKey ) {
1960  $incKey = "resourceloader_cache.$filter.miss";
1961  return self::applyFilter( $filter, $data );
1962  }
1963  );
1964  $stats->increment( $incKey );
1965 
1966  // Use $data on cache failure
1967  return $result ?? $data;
1968  }
1969 
1975  private static function applyFilter( $filter, $data ) {
1976  $data = trim( $data );
1977  if ( $data ) {
1978  try {
1979  $data = ( $filter === 'minify-css' )
1980  ? CSSMin::minify( $data )
1981  : JavaScriptMinifier::minify( $data );
1982  } catch ( TimeoutException $e ) {
1983  throw $e;
1984  } catch ( Exception $e ) {
1986  return null;
1987  }
1988  }
1989  return $data;
1990  }
1991 
2003  public static function getUserDefaults(
2004  Context $context,
2005  HookContainer $hookContainer,
2006  UserOptionsLookup $userOptionsLookup
2007  ): array {
2008  $defaultOptions = $userOptionsLookup->getDefaultOptions();
2009  $keysToExclude = [];
2010  $hookRunner = new HookRunner( $hookContainer );
2011  $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2012  foreach ( $keysToExclude as $excludedKey ) {
2013  unset( $defaultOptions[ $excludedKey ] );
2014  }
2015  return $defaultOptions;
2016  }
2017 
2026  public static function getSiteConfigSettings(
2027  Context $context, Config $conf
2028  ): array {
2029  $services = MediaWikiServices::getInstance();
2030  // Namespace related preparation
2031  // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2032  // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2033  $contLang = $services->getContentLanguage();
2034  $namespaceIds = $contLang->getNamespaceIds();
2035  $caseSensitiveNamespaces = [];
2036  $nsInfo = $services->getNamespaceInfo();
2037  foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2038  $namespaceIds[$contLang->lc( $name )] = $index;
2039  if ( !$nsInfo->isCapitalized( $index ) ) {
2040  $caseSensitiveNamespaces[] = $index;
2041  }
2042  }
2043 
2044  $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2045 
2046  // Build list of variables
2047  $skin = $context->getSkin();
2048 
2049  // Start of supported and stable config vars (for use by extensions/gadgets).
2050  $vars = [
2051  'debug' => $context->getDebug(),
2052  'skin' => $skin,
2053  'stylepath' => $conf->get( MainConfigNames::StylePath ),
2054  'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2055  'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2056  'wgScript' => $conf->get( MainConfigNames::Script ),
2057  'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2058  'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2059  'wgServer' => $conf->get( MainConfigNames::Server ),
2060  'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2061  'wgUserLanguage' => $context->getLanguage(),
2062  'wgContentLanguage' => $contLang->getCode(),
2063  'wgVersion' => MW_VERSION,
2064  'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2065  'wgNamespaceIds' => $namespaceIds,
2066  'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2067  'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2068  'wgDBname' => $conf->get( MainConfigNames::DBname ),
2069  'wgWikiID' => WikiMap::getCurrentWikiId(),
2070  'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2071  'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2072  'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2073  ];
2074  // End of stable config vars.
2075 
2076  // Internal variables for use by MediaWiki core and/or ResourceLoader.
2077  $vars += [
2078  // @internal For mediawiki.widgets
2079  'wgUrlProtocols' => wfUrlProtocols(),
2080  // @internal For mediawiki.page.watch
2081  // Force object to avoid "empty" associative array from
2082  // becoming [] instead of {} in JS (T36604)
2083  'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2084  // @internal For mediawiki.language
2085  'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2086  // @internal For mediawiki.Title
2087  'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2088  'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2089  'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2090  ];
2091 
2092  ( new HookRunner( $services->getHookContainer() ) )
2093  ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2094 
2095  return $vars;
2096  }
2097 
2102  public function getErrors() {
2103  return $this->errors;
2104  }
2105 }
2106 
2107 class_alias( ResourceLoader::class, 'ResourceLoader' );
const CACHE_ANYTHING
Definition: Defines.php:85
const MW_VERSION
The running version of MediaWiki.
Definition: Defines.php:36
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfUrlProtocols( $includeProtocolRelative=true)
Returns a regular expression of url protocols.
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
wfResetOutputBuffers( $resetGzipEncoding=true)
Clear away any user-level output buffers, discarding contents.
$modules
if(!defined( 'MEDIAWIKI')) if(ini_get( 'mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition: Setup.php:94
global $wgRequest
Definition: Setup.php:408
const MW_ENTRY_POINT
Definition: api.php:44
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:85
Class for managing the deferral of updates within the scope of a PHP script invocation.
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Load JSON files, and uses a Processor to extract information.
isCacheGood( $timestamp='')
Check if up to date cache file exists.
fetchText()
Get the uncompressed text from the cache.
cacheTimestamp()
Get the last-modified timestamp of the cache file.
Simple store for keeping values in an associative array for the current process.
static header( $code)
Output an HTTP status code header.
Definition: HttpStatus.php:96
Handler class for MWExceptions.
static getRedactedTraceAsString(Throwable $e)
Generate a string representation of a throwable's stack trace.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
static getPublicLogMessage(Throwable $e)
static getLogMessage(Throwable $e)
Get a message formatting the throwable message and its origin.
Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
Handle database storage of comments such as edit summaries and log reasons.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:565
This class is a collection of static functions that serve two purposes:
Definition: Html.php:55
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Class for tracking request-level classification information for profiling/stats/logging.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition: Context.php:46
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode.
Definition: Context.php:494
getImageObj()
If this is a request for an image, get the Image object.
Definition: Context.php:375
This class generates message blobs for use by ResourceLoader.
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition: Module.php:641
static getVary(Context $context)
Get vary string.
Definition: Module.php:1111
ResourceLoader is a loading system for JavaScript and CSS resources.
getLoadScript( $source)
Get the URL to the load.php endpoint for the given ResourceLoader source.
static makeComment( $text)
Generate a CSS or JS comment block.
respond(Context $context)
Output a response to a load request, including the content-type header.
isModuleRegistered( $name)
Check whether a ResourceLoader module is registered.
loadModuleDependenciesInternal( $moduleName, $variant)
preloadModuleInfo(array $moduleNames, Context $context)
Load information stored in the database and dependency tracking store about modules.
static formatException(Throwable $e)
Handle exception display.
__construct(Config $config, LoggerInterface $logger=null, DependencyStore $tracker=null, array $params=[])
getSources()
Get the list of sources.
setMessageBlobStore(MessageBlobStore $blobStore)
tryRespondNotModified(Context $context, $etag)
Respond with HTTP 304 Not Modified if appropriate.
static formatExceptionNoComment(Throwable $e)
Handle exception display.
sendResponseHeaders(Context $context, $etag, $errors, array $extra=[])
Send main response headers to the client.
measureResponseTime()
Send stats about the time used to build the response.
setDependencyStore(DependencyStore $tracker)
static makeHash( $value)
Create a hash for module versioning purposes.
array $errors
Errors accumulated during a respond() call.
saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths)
getTestSuiteModuleNames()
Get a list of modules with QUnit tests.
makeModuleResponse(Context $context, array $modules, array $missing=[])
Generate code for a response.
getModule( $name)
Get the Module object for a given module name.
setModuleSkinStyles(array $moduleSkinStyles)
outputErrorAndLog(Exception $e, $msg, array $context=[])
Add an error to the 'errors' array and log it.
makeVersionQuery(Context $context, array $modules)
Get the expected value of the 'version' query parameter.
tryRespondFromFileCache(ResourceFileCache $fileCache, Context $context, $etag)
Send out code for a response from file cache if possible.
string[] $extraHeaders
Buffer for extra response headers during a makeModuleResponse() call.
getCombinedVersion(Context $context, array $moduleNames)
Helper method to get and combine versions of multiple modules.
addSource( $sources, $loadUrl=null)
Add a foreign source of modules.
static preloadTitleInfo(Context $context, IDatabase $db, array $moduleNames)
Definition: WikiModule.php:589
Represents a title within MediaWiki.
Definition: Title.php:82
Provides access to user options.
Helper tools for dealing with other locally-hosted wikis.
Definition: WikiMap.php:33
Functions to get cache objects.
Definition: ObjectCache.php:66
static getLocalServerInstance( $fallback=CACHE_NONE)
Factory function for CACHE_ACCEL (referenced from configuration)
static getLocalClusterInstance()
Get the main cluster-local cache object.
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:60
static transformCssMedia( $media)
Transform "media" attribute based on request parameters.
ResourceLoader request result caching in the file system.
static useFileCache(RL\Context $context)
Check if an RL request can be cached.
static newFromContext(RL\Context $context)
Construct an ResourceFileCache from a context.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:50
const GETHEADER_LIST
Flag to make WebRequest::getHeader return an array of values.
Definition: WebRequest.php:80
Track per-module dependency file paths that are expensive to mass compute.
Track per-module file dependencies in object cache via BagOStuff.
A wrapper class which causes Xml::encodeJsVar() and Xml::encodeJsCall() to interpret a given string a...
Definition: XmlJsCode.php:40
static encodeObject( $obj, $pretty=false)
Encode an object containing XmlJsCode objects.
Definition: XmlJsCode.php:59
Module of static functions for generating XML.
Definition: Xml.php:31
static encodeJsCall( $name, $args, $pretty=false)
Create a call to a JavaScript function.
Definition: Xml.php:694
Interface for configuration instances.
Definition: Config.php:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source
const DB_REPLICA
Definition: defines.php:26
$content
Definition: router.php:76
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
if(!isset( $args[0])) $lang
if(count( $args)< 1) $tracker
Definition: trackBlobs.php:37
$header