MediaWiki master
ResourceLoader.php
Go to the documentation of this file.
1<?php
24
25use BagOStuff;
26use Exception;
29use HttpStatus;
30use InvalidArgumentException;
31use Less_Environment;
32use Less_Parser;
33use LogicException;
51use Net_URL2;
52use ObjectCache;
53use Psr\Log\LoggerAwareInterface;
54use Psr\Log\LoggerInterface;
55use Psr\Log\NullLogger;
56use RuntimeException;
57use stdClass;
58use Throwable;
59use UnexpectedValueException;
62use Wikimedia\Minify\CSSMin;
63use Wikimedia\Minify\IdentityMinifierState;
64use Wikimedia\Minify\IndexMap;
65use Wikimedia\Minify\IndexMapOffset;
66use Wikimedia\Minify\JavaScriptMapperState;
67use Wikimedia\Minify\JavaScriptMinifier;
68use Wikimedia\Minify\JavaScriptMinifierState;
69use Wikimedia\Minify\MinifierState;
70use Wikimedia\RequestTimeout\TimeoutException;
71use Wikimedia\ScopedCallback;
73use Wikimedia\Timestamp\ConvertibleTimestamp;
74use Wikimedia\WrappedString;
75
96class ResourceLoader implements LoggerAwareInterface {
98 public const CACHE_VERSION = 9;
100 public const FILTER_NOMIN = '/*@nomin*/';
101
103 private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
105 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
107 private const MAXAGE_RECOVER = 60;
108
110 protected static $debugMode = null;
111
113 private $config;
115 private $blobStore;
117 private $depStore;
119 private $logger;
121 private $hookContainer;
123 private $srvCache;
125 private $statsFactory;
127 private $maxageVersioned;
129 private $maxageUnversioned;
130
132 private $modules = [];
134 private $moduleInfos = [];
136 private $testModuleNames = [];
138 private $sources = [];
140 protected $errors = [];
145 protected $extraHeaders = [];
147 private $depStoreUpdateBuffer = [];
152 private $moduleSkinStyles = [];
153
174 public function __construct(
175 Config $config,
176 LoggerInterface $logger = null,
177 DependencyStore $tracker = null,
178 array $params = []
179 ) {
180 $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
181 $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
182
183 $this->config = $config;
184 $this->logger = $logger ?: new NullLogger();
185
186 $services = MediaWikiServices::getInstance();
187 $this->hookContainer = $services->getHookContainer();
188
189 $this->srvCache = $services->getLocalServerObjectCache();
190 $this->statsFactory = $services->getStatsFactory();
191
192 // Add 'local' source first
193 $this->addSource( 'local', $params['loadScript'] ?? '/load.php' );
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
202 $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
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
249 public function setDependencyStore( DependencyStore $tracker ) {
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 WikiModule::preloadTitleInfo( $context, $moduleNames );
463
464 // Prime in-object cache for message blobs for modules with messages
465 $modulesWithMessages = [];
466 foreach ( $moduleNames as $moduleName ) {
467 $module = $this->getModule( $moduleName );
468 if ( $module && $module->getMessages() ) {
469 $modulesWithMessages[$moduleName] = $module;
470 }
471 }
472 // Prime in-object cache for message blobs for modules with messages
473 $lang = $context->getLanguage();
474 $store = $this->getMessageBlobStore();
475 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
476 foreach ( $blobs as $moduleName => $blob ) {
477 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
478 }
479 }
480
487 public function loadModuleDependenciesInternal( $moduleName, $variant ) {
488 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
489
490 return Module::expandRelativePaths( $deps['paths'] );
491 }
492
500 public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
501 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
502 $entity = "$moduleName|$variant";
503
504 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
505 // Dependency store needs to be updated with the new path list
506 if ( $paths ) {
507 $deps = $this->depStore->newEntityDependencies( $paths, time() );
508 $this->depStoreUpdateBuffer[$entity] = $deps;
509 } else {
510 $this->depStoreUpdateBuffer[$entity] = null;
511 }
512 }
513
514 // If paths were unchanged, leave the dependency store unchanged also.
515 // The entry will eventually expire, after which we will briefly issue an incomplete
516 // version hash for a 5-min startup window, the module then recomputes and rediscovers
517 // the paths and arrive at the same module version hash once again. It will churn
518 // part of the browser cache once, for clients connecting during that window.
519
520 if ( !$hasPendingUpdate ) {
521 DeferredUpdates::addCallableUpdate( function () {
522 $updatesByEntity = $this->depStoreUpdateBuffer;
523 $this->depStoreUpdateBuffer = [];
524 $cache = ObjectCache::getLocalClusterInstance();
525
526 $scopeLocks = [];
527 $depsByEntity = [];
528 $entitiesUnreg = [];
529 foreach ( $updatesByEntity as $entity => $update ) {
530 $lockKey = $cache->makeKey( 'rl-deps', $entity );
531 $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
532 if ( !$scopeLocks[$entity] ) {
533 // avoid duplicate write request slams (T124649)
534 // the lock must be specific to the current wiki (T247028)
535 continue;
536 }
537 if ( $update === null ) {
538 $entitiesUnreg[] = $entity;
539 } else {
540 $depsByEntity[$entity] = $update;
541 }
542 }
543
544 $ttl = self::RL_MODULE_DEP_TTL;
545 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
546 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
547 } );
548 }
549 }
550
556 public function getSources() {
557 return $this->sources;
558 }
559
568 public function getLoadScript( $source ) {
569 if ( !isset( $this->sources[$source] ) ) {
570 throw new UnexpectedValueException( "Unknown source '$source'" );
571 }
572 return $this->sources[$source];
573 }
574
578 public const HASH_LENGTH = 5;
579
642 public static function makeHash( $value ) {
643 $hash = hash( 'fnv132', $value );
644 // The base_convert will pad it (if too short),
645 // then substr() will trim it (if too long).
646 return substr(
647 \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
648 0,
649 self::HASH_LENGTH
650 );
651 }
652
662 public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
663 MWExceptionHandler::logException( $e );
664 $this->logger->warning(
665 $msg,
666 $context + [ 'exception' => $e ]
667 );
668 $this->errors[] = self::formatExceptionNoComment( $e );
669 }
670
679 public function getCombinedVersion( Context $context, array $moduleNames ) {
680 if ( !$moduleNames ) {
681 return '';
682 }
683 $hashes = [];
684 foreach ( $moduleNames as $module ) {
685 try {
686 $hash = $this->getModule( $module )->getVersionHash( $context );
687 } catch ( TimeoutException $e ) {
688 throw $e;
689 } catch ( Exception $e ) {
690 // If modules fail to compute a version, don't fail the request (T152266)
691 // and still compute versions of other modules.
692 $this->outputErrorAndLog( $e,
693 'Calculating version for "{module}" failed: {exception}',
694 [
695 'module' => $module,
696 ]
697 );
698 $hash = '';
699 }
700 $hashes[] = $hash;
701 }
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 if ( $context->isSourceMap() ) {
798 // In source map mode, a version mismatch should be a 404
799 if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) {
800 ob_end_clean();
801 $this->sendSourceMapVersionMismatch( $versionHash );
802 return;
803 }
804 // No source maps for images, only=styles requests, or debug mode
805 if ( $context->getImage()
806 || $context->getOnly() === 'styles'
807 || $context->getDebug()
808 ) {
809 ob_end_clean();
810 $this->sendSourceMapTypeNotImplemented();
811 return;
812 }
813 }
814 // Emit source map header if supported (inverse of the above check)
816 && !$context->getImageObj()
817 && !$context->isSourceMap()
818 && $context->shouldIncludeScripts()
819 && !$context->getDebug()
820 ) {
821 $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
822 }
823
824 // Generate a response
825 $response = $this->makeModuleResponse( $context, $modules, $missing );
826
827 // Capture any PHP warnings from the output buffer and append them to the
828 // error list if we're in debug mode.
829 if ( $context->getDebug() ) {
830 $warnings = ob_get_contents();
831 if ( strlen( $warnings ) ) {
832 $this->errors[] = $warnings;
833 }
834 }
835
836 $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
837
838 // Remove the output buffer and output the response
839 ob_end_clean();
840
841 if ( $context->getImageObj() && $this->errors ) {
842 // We can't show both the error messages and the response when it's an image.
843 $response = implode( "\n\n", $this->errors );
844 } elseif ( $this->errors ) {
845 $errorText = implode( "\n\n", $this->errors );
846 $errorResponse = self::makeComment( $errorText );
847 if ( $context->shouldIncludeScripts() ) {
848 $errorResponse .= 'if (window.console && console.error) { console.error('
849 . $context->encodeJson( $errorText )
850 . "); }\n";
851 // Append the error info to the response
852 // We used to prepend it, but that would corrupt the source map
853 $response .= $errorResponse;
854 } else {
855 // For styles we can still prepend
856 $response = $errorResponse . $response;
857 }
858 }
859
860 // @phan-suppress-next-line SecurityCheck-XSS
861 echo $response;
862 }
863
868 protected function measureResponseTime() {
869 $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
870 return new ScopedCallback( function () use ( $statStart ) {
871 $statTiming = microtime( true ) - $statStart;
872
873 $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' )
874 ->copyToStatsdAt( 'resourceloader.responseTime' )
875 ->observe( 1000 * $statTiming );
876 } );
877 }
878
889 protected function sendResponseHeaders(
890 Context $context, $etag, $errors, array $extra = []
891 ): void {
892 HeaderCallback::warnIfHeadersSent();
893
894 if ( $errors ) {
895 $maxage = self::MAXAGE_RECOVER;
896 } elseif (
897 $context->getVersion() !== null
898 && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
899 ) {
900 // If we need to self-correct, set a very short cache expiry
901 // to basically just debounce CDN traffic. This applies to:
902 // - Internal errors, e.g. due to misconfiguration.
903 // - Version mismatch, e.g. due to deployment race (T117587, T47877).
904 $this->logger->debug( 'Client and server registry version out of sync' );
905 $maxage = self::MAXAGE_RECOVER;
906 } elseif ( $context->getVersion() === null ) {
907 // Resources that can't set a version, should have their updates propagate to
908 // clients quickly. This applies to shared resources linked from HTML, such as
909 // the startup module and stylesheets.
910 $maxage = $this->maxageUnversioned;
911 } else {
912 // When a version is set, use a long expiry because changes
913 // will naturally miss the cache by using a different URL.
914 $maxage = $this->maxageVersioned;
915 }
916 if ( $context->getImageObj() ) {
917 // Output different headers if we're outputting textual errors.
918 if ( $errors ) {
919 header( 'Content-Type: text/plain; charset=utf-8' );
920 } else {
921 $context->getImageObj()->sendResponseHeaders( $context );
922 }
923 } elseif ( $context->isSourceMap() ) {
924 header( 'Content-Type: application/json' );
925 } elseif ( $context->getOnly() === 'styles' ) {
926 header( 'Content-Type: text/css; charset=utf-8' );
927 header( 'Access-Control-Allow-Origin: *' );
928 } else {
929 header( 'Content-Type: text/javascript; charset=utf-8' );
930 }
931 // See RFC 2616 § 14.19 ETag
932 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
933 header( 'ETag: ' . $etag );
934 if ( $context->getDebug() ) {
935 // Do not cache debug responses
936 header( 'Cache-Control: private, no-cache, must-revalidate' );
937 } else {
938 // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
939 // the background instead of blocking the next page load (eg. startup module, or CSS).
940 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
941 ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
942 : ''
943 );
944 header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
945 header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
946 }
947 foreach ( $extra as $header ) {
948 header( $header );
949 }
950 }
951
962 protected function tryRespondNotModified( Context $context, $etag ) {
963 // See RFC 2616 § 14.26 If-None-Match
964 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
965 $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
966 // Never send 304s in debug mode
967 if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
968 // There's another bug in ob_gzhandler (see also the comment at
969 // the top of this function) that causes it to gzip even empty
970 // responses, meaning it's impossible to produce a truly empty
971 // response (because the gzip header is always there). This is
972 // a problem because 304 responses have to be completely empty
973 // per the HTTP spec, and Firefox behaves buggily when they're not.
974 // See also https://bugs.php.net/bug.php?id=51579
975 // To work around this, we tear down all output buffering before
976 // sending the 304.
977 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
978
979 HttpStatus::header( 304 );
980
981 $this->sendResponseHeaders( $context, $etag, false );
982 return true;
983 }
984 return false;
985 }
986
994 private function getSourceMapUrl( Context $context, $version ) {
995 return $this->createLoaderURL( 'local', $context, [
996 'sourcemap' => '1',
997 'version' => $version
998 ] );
999 }
1000
1006 private function sendSourceMapVersionMismatch( $currentVersion ) {
1007 HttpStatus::header( 404 );
1008 header( 'Content-Type: text/plain; charset=utf-8' );
1009 header( 'X-Content-Type-Options: nosniff' );
1010 echo "Can't deliver a source map for the requested version " .
1011 "since the version is now '$currentVersion'\n";
1012 }
1013
1018 private function sendSourceMapTypeNotImplemented() {
1019 HttpStatus::header( 404 );
1020 header( 'Content-Type: text/plain; charset=utf-8' );
1021 header( 'X-Content-Type-Options: nosniff' );
1022 echo "Can't make a source map for this content type\n";
1023 }
1024
1033 public static function makeComment( $text ) {
1034 $encText = str_replace( '*/', '* /', $text );
1035 return "/*\n$encText\n*/\n";
1036 }
1037
1044 public static function formatException( Throwable $e ) {
1045 return self::makeComment( self::formatExceptionNoComment( $e ) );
1046 }
1047
1055 protected static function formatExceptionNoComment( Throwable $e ) {
1056 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1057 return MWExceptionHandler::getPublicLogMessage( $e );
1058 }
1059
1060 return MWExceptionHandler::getLogMessage( $e ) .
1061 "\nBacktrace:\n" .
1062 MWExceptionHandler::getRedactedTraceAsString( $e );
1063 }
1064
1076 public function makeModuleResponse( Context $context,
1077 array $modules, array $missing = []
1078 ) {
1079 if ( $modules === [] && $missing === [] ) {
1080 return <<<MESSAGE
1081/* This file is the Web entry point for MediaWiki's ResourceLoader:
1082 <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1083 no modules were requested. Max made me put this here. */
1084MESSAGE;
1085 }
1086
1087 $image = $context->getImageObj();
1088 if ( $image ) {
1089 $data = $image->getImageData( $context );
1090 if ( $data === false ) {
1091 $data = '';
1092 $this->errors[] = 'Image generation failed';
1093 }
1094 return $data;
1095 }
1096
1097 $states = [];
1098 foreach ( $missing as $name ) {
1099 $states[$name] = 'missing';
1100 }
1101
1102 $only = $context->getOnly();
1103 $debug = (bool)$context->getDebug();
1104 if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1105 $indexMap = new IndexMap;
1106 } else {
1107 $indexMap = null;
1108 }
1109
1110 $out = '';
1111 foreach ( $modules as $name => $module ) {
1112 try {
1113 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1114 if ( $indexMap ) {
1115 $indexMap->addEncodedMap( $response, $offset );
1116 } else {
1117 $out .= $response;
1118 }
1119 } catch ( TimeoutException $e ) {
1120 throw $e;
1121 } catch ( Exception $e ) {
1122 $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1123
1124 // Respond to client with error-state instead of module implementation
1125 $states[$name] = 'error';
1126 unset( $modules[$name] );
1127 }
1128 }
1129
1130 // Update module states
1131 if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1132 if ( $modules && $only === 'scripts' ) {
1133 // Set the state of modules loaded as only scripts to ready as
1134 // they don't have an mw.loader.impl wrapper that sets the state
1135 foreach ( $modules as $name => $module ) {
1136 $states[$name] = 'ready';
1137 }
1138 }
1139
1140 // Set the state of modules we didn't respond to with mw.loader.impl
1141 if ( $states && !$context->isSourceMap() ) {
1142 $stateScript = self::makeLoaderStateScript( $context, $states );
1143 if ( !$debug ) {
1144 $stateScript = self::filter( 'minify-js', $stateScript );
1145 }
1146 // Use a linebreak between module script and state script (T162719)
1147 $out = self::ensureNewline( $out ) . $stateScript;
1148 }
1149 } elseif ( $states ) {
1150 $this->errors[] = 'Problematic modules: '
1151 // Silently ignore invalid UTF-8 injected via 'modules' query
1152 // Don't issue server-side warnings for client errors. (T331641)
1153 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1154 . @$context->encodeJson( $states );
1155 }
1156
1157 if ( $indexMap ) {
1158 return $indexMap->getMap();
1159 } else {
1160 return $out;
1161 }
1162 }
1163
1172 private function getOneModuleResponse( Context $context, $name, Module $module ) {
1173 $only = $context->getOnly();
1174 // Important: Do not cache minifications of embedded modules
1175 // This is especially for the private 'user.options' module,
1176 // which varies on every pageview and would explode the cache (T84960)
1177 $shouldCache = !$module->shouldEmbedModule( $context );
1178 if ( $only === 'styles' ) {
1179 $minifier = new IdentityMinifierState;
1180 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1181 // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper
1182 // to ease code reuse. The filter() call below performs CSS minification.
1183 $styles = $minifier->getMinifiedOutput();
1184 if ( $context->getDebug() ) {
1185 return [ $styles, null ];
1186 }
1187 return [
1188 self::filter( 'minify-css', $styles,
1189 [ 'cache' => $shouldCache ] ),
1190 null
1191 ];
1192 }
1193
1194 $minifier = new IdentityMinifierState;
1195 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1196 $plainContent = $minifier->getMinifiedOutput();
1197 if ( $context->getDebug() ) {
1198 return [ $plainContent, null ];
1199 }
1200
1201 $isHit = true;
1202 $callback = function () use ( $context, $name, $module, &$isHit ) {
1203 $isHit = false;
1204 if ( $context->isSourceMap() ) {
1205 $minifier = ( new JavaScriptMapperState )
1206 ->outputFile( $this->createLoaderURL( 'local', $context, [
1207 'modules' => self::makePackedModulesString( $context->getModules() ),
1208 'only' => $context->getOnly()
1209 ] ) );
1210 } else {
1211 $minifier = new JavaScriptMinifierState;
1212 }
1213 // We only need to add one set of headers, and we did that for the identity response
1214 $discardedHeaders = null;
1215 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1216 if ( $context->isSourceMap() ) {
1217 $sourceMap = $minifier->getRawSourceMap();
1218 $generated = $minifier->getMinifiedOutput();
1219 $offset = IndexMapOffset::newFromText( $generated );
1220 return [ $sourceMap, $offset->toArray() ];
1221 } else {
1222 return [ $minifier->getMinifiedOutput(), null ];
1223 }
1224 };
1225
1226 if ( $shouldCache ) {
1227 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1228 $this->srvCache->makeGlobalKey(
1229 'resourceloader-mapped',
1230 self::CACHE_VERSION,
1231 $name,
1232 $context->isSourceMap() ? '1' : '0',
1233 md5( $plainContent )
1234 ),
1235 BagOStuff::TTL_DAY,
1236 $callback
1237 );
1238
1239 $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js';
1240 $statsdNamespace = implode( '.', [
1241 "resourceloader_cache", $mapType, $isHit ? 'hit' : 'miss'
1242 ] );
1243 $this->statsFactory->getCounter( 'resourceloader_cache_total' )
1244 ->setLabel( 'type', $mapType )
1245 ->setLabel( 'status', $isHit ? 'hit' : 'miss' )
1246 ->copyToStatsdAt( [ $statsdNamespace ] )
1247 ->increment();
1248 } else {
1249 [ $response, $offsetArray ] = $callback();
1250 }
1251 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1252
1253 return [ $response, $offset ];
1254 }
1255
1266 private function addOneModuleResponse(
1267 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1268 ) {
1269 $only = $context->getOnly();
1270 $debug = (bool)$context->getDebug();
1271 $content = $module->getModuleContent( $context );
1272 $version = $module->getVersionHash( $context );
1273
1274 if ( $headers !== null && isset( $content['headers'] ) ) {
1275 $headers = array_merge( $headers, $content['headers'] );
1276 }
1277
1278 // Append output
1279 switch ( $only ) {
1280 case 'scripts':
1281 $scripts = $content['scripts'];
1282 if ( !is_array( $scripts ) ) {
1283 // Formerly scripts was usually a string, but now it is
1284 // normalized to an array by buildContent().
1285 throw new InvalidArgumentException( 'scripts must be an array' );
1286 }
1287 if ( isset( $scripts['plainScripts'] ) ) {
1288 // Add plain scripts
1289 $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1290 } elseif ( isset( $scripts['files'] ) ) {
1291 // Add implement call if any
1292 $this->addImplementScript(
1293 $minifier,
1294 $name,
1295 $version,
1296 $scripts,
1297 [],
1298 null,
1299 [],
1300 $content['deprecationWarning'] ?? null
1301 );
1302 }
1303 break;
1304 case 'styles':
1305 $styles = $content['styles'];
1306 // We no longer separate into media, they are all combined now with
1307 // custom media type groups into @media .. {} sections as part of the css string.
1308 // Module returns either an empty array or a numerical array with css strings.
1309 if ( isset( $styles['css'] ) ) {
1310 $minifier->addOutput( implode( '', $styles['css'] ) );
1311 }
1312 break;
1313 default:
1314 $scripts = $content['scripts'] ?? '';
1315 if ( ( $name === 'site' || $name === 'user' )
1316 && isset( $scripts['plainScripts'] )
1317 ) {
1318 // Legacy scripts that run in the global scope without a closure.
1319 // mw.loader.impl will use eval if scripts is a string.
1320 // Minify manually here, because general response minification is
1321 // not effective due it being a string literal, not a function.
1322 $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1323 if ( !$debug ) {
1324 $scripts = self::filter( 'minify-js', $scripts ); // T107377
1325 }
1326 }
1327 $this->addImplementScript(
1328 $minifier,
1329 $name,
1330 $version,
1331 $scripts,
1332 $content['styles'] ?? [],
1333 isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1334 $content['templates'] ?? [],
1335 $content['deprecationWarning'] ?? null
1336 );
1337 break;
1338 }
1339 $minifier->ensureNewline();
1340 }
1341
1348 public static function ensureNewline( $str ) {
1349 $end = substr( $str, -1 );
1350 if ( $end === false || $end === '' || $end === "\n" ) {
1351 return $str;
1352 }
1353 return $str . "\n";
1354 }
1355
1362 public function getModulesByMessage( $messageKey ) {
1363 $moduleNames = [];
1364 foreach ( $this->getModuleNames() as $moduleName ) {
1365 $module = $this->getModule( $moduleName );
1366 if ( in_array( $messageKey, $module->getMessages() ) ) {
1367 $moduleNames[] = $moduleName;
1368 }
1369 }
1370 return $moduleNames;
1371 }
1372
1394 private function addImplementScript( MinifierState $minifier,
1395 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1396 ) {
1397 $implementKey = "$moduleName@$version";
1398 // Plain functions are used instead of arrow functions to avoid
1399 // defeating lazy compilation on Chrome. (T343407)
1400 $minifier->addOutput( "mw.loader.impl(function(){return[" .
1401 Html::encodeJsVar( $implementKey ) . "," );
1402
1403 // Scripts
1404 if ( is_string( $scripts ) ) {
1405 // user/site script
1406 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1407 } elseif ( is_array( $scripts ) ) {
1408 if ( isset( $scripts['files'] ) ) {
1409 $minifier->addOutput(
1410 "{\"main\":" .
1411 Html::encodeJsVar( $scripts['main'] ) .
1412 ",\"files\":" );
1413 $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1414 $minifier->addOutput( "}" );
1415 } elseif ( isset( $scripts['plainScripts'] ) ) {
1416 if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1417 $minifier->addOutput( 'null' );
1418 } else {
1419 $minifier->addOutput( "function($,jQuery,require,module){" );
1420 $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1421 $minifier->addOutput( "}" );
1422 }
1423 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1424 // Array of URLs
1425 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1426 } else {
1427 throw new InvalidArgumentException( 'Invalid script array: ' .
1428 'must contain files, plainScripts or be an array of URLs' );
1429 }
1430 } else {
1431 throw new InvalidArgumentException( 'Script must be a string or array' );
1432 }
1433
1434 // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1435 // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1436 // of "{}". Force them to objects.
1437 $extraArgs = [
1438 (object)$styles,
1439 $messages ?? (object)[],
1440 (object)$templates,
1441 $deprecationWarning
1442 ];
1443 self::trimArray( $extraArgs );
1444 foreach ( $extraArgs as $arg ) {
1445 $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1446 }
1447 $minifier->addOutput( "];});" );
1448 }
1449
1460 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1461 $first = true;
1462 $minifier->addOutput( "{" );
1463 foreach ( $files as $fileName => $file ) {
1464 if ( $first ) {
1465 $first = false;
1466 } else {
1467 $minifier->addOutput( "," );
1468 }
1469 $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1470 $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1471 }
1472 $minifier->addOutput( "}" );
1473 }
1474
1484 private function addFileContent( MinifierState $minifier,
1485 $moduleName, $sourceType, $sourceIndex, array $file
1486 ) {
1487 $isScript = ( $file['type'] ?? 'script' ) === 'script';
1489 $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1490 if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1491 $url = $filePath->getRemotePath();
1492 } else {
1493 $ext = $isScript ? 'js' : 'json';
1494 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1495 ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1496 $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1497 }
1498 $content = $file['content'];
1499 if ( $isScript ) {
1500 if ( $sourceType === 'packageFile' ) {
1501 // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1502 // $/jQuery are simply used as globals instead.
1503 // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1504 $minifier->addOutput( "function(require,module,exports){" );
1505 $minifier->addSourceFile( $url, $content, true );
1506 $minifier->ensureNewline();
1507 $minifier->addOutput( "}" );
1508 } else {
1509 $minifier->addSourceFile( $url, $content, true );
1510 $minifier->ensureNewline();
1511 }
1512 } else {
1513 $content = Html::encodeJsVar( $content, true );
1514 $minifier->addSourceFile( $url, $content, true );
1515 }
1516 }
1517
1525 private static function concatenatePlainScripts( $plainScripts ) {
1526 $s = '';
1527 foreach ( $plainScripts as $script ) {
1528 // Make the script safe to concatenate by making sure there is at least one
1529 // trailing new line at the end of the content (T29054, T162719)
1530 $s .= self::ensureNewline( $script['content'] );
1531 }
1532 return $s;
1533 }
1534
1543 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1544 foreach ( $plainScripts as $index => $file ) {
1545 $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1546 }
1547 }
1548
1555 private function isEmptyFileInfos( $infos ) {
1556 $len = 0;
1557 foreach ( $infos as $info ) {
1558 $len += strlen( $info['content'] ?? '' );
1559 }
1560 return $len === 0;
1561 }
1562
1570 public static function makeCombinedStyles( array $stylePairs ) {
1571 $out = [];
1572 foreach ( $stylePairs as $media => $styles ) {
1573 // FileModule::getStyle can return the styles as a string or an
1574 // array of strings. This is to allow separation in the front-end.
1575 $styles = (array)$styles;
1576 foreach ( $styles as $style ) {
1577 $style = trim( $style );
1578 // Don't output an empty "@media print { }" block (T42498)
1579 if ( $style === '' ) {
1580 continue;
1581 }
1582 // Transform the media type based on request params and config
1583 // The way that this relies on $wgRequest to propagate request params is slightly evil
1584 $media = OutputPage::transformCssMedia( $media );
1585
1586 if ( $media === '' || $media == 'all' ) {
1587 $out[] = $style;
1588 } elseif ( is_string( $media ) ) {
1589 $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1590 }
1591 // else: skip
1592 }
1593 }
1594 return $out;
1595 }
1596
1604 private static function encodeJsonForScript( $data ) {
1605 // Keep output as small as possible by disabling needless escape modes
1606 // that PHP uses by default.
1607 // However, while most module scripts are only served on HTTP responses
1608 // for JavaScript, some modules can also be embedded in the HTML as inline
1609 // scripts. This, and the fact that we sometimes need to export strings
1610 // containing user-generated content and labels that may genuinely contain
1611 // a sequences like "</script>", we need to encode either '/' or '<'.
1612 // By default PHP escapes '/'. Let's escape '<' instead which is less common
1613 // and allows URLs to mostly remain readable.
1614 $jsonFlags = JSON_UNESCAPED_SLASHES |
1615 JSON_UNESCAPED_UNICODE |
1616 JSON_HEX_TAG |
1617 JSON_HEX_AMP;
1618 if ( self::inDebugMode() ) {
1619 $jsonFlags |= JSON_PRETTY_PRINT;
1620 }
1621 return json_encode( $data, $jsonFlags );
1622 }
1623
1632 public static function makeLoaderStateScript(
1633 Context $context, array $states
1634 ) {
1635 return 'mw.loader.state('
1636 // Silently ignore invalid UTF-8 injected via 'modules' query
1637 // Don't issue server-side warnings for client errors. (T331641)
1638 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1639 . @$context->encodeJson( $states )
1640 . ');';
1641 }
1642
1643 private static function isEmptyObject( stdClass $obj ) {
1644 foreach ( $obj as $value ) {
1645 return false;
1646 }
1647 return true;
1648 }
1649
1663 private static function trimArray( array &$array ): void {
1664 $i = count( $array );
1665 while ( $i-- ) {
1666 if ( $array[$i] === null
1667 || $array[$i] === []
1668 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1669 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1670 ) {
1671 unset( $array[$i] );
1672 } else {
1673 break;
1674 }
1675 }
1676 }
1677
1703 public static function makeLoaderRegisterScript(
1704 Context $context, array $modules
1705 ) {
1706 // Optimisation: Transform dependency names into indexes when possible
1707 // to produce smaller output. They are expanded by mw.loader.register on
1708 // the other end.
1709 $index = [];
1710 foreach ( $modules as $i => $module ) {
1711 // Build module name index
1712 $index[$module[0]] = $i;
1713 }
1714 foreach ( $modules as &$module ) {
1715 if ( isset( $module[2] ) ) {
1716 foreach ( $module[2] as &$dependency ) {
1717 if ( isset( $index[$dependency] ) ) {
1718 // Replace module name in dependency list with index
1719 $dependency = $index[$dependency];
1720 }
1721 }
1722 }
1723 self::trimArray( $module );
1724 }
1725
1726 return 'mw.loader.register('
1727 . $context->encodeJson( $modules )
1728 . ');';
1729 }
1730
1744 public static function makeLoaderSourcesScript(
1745 Context $context, array $sources
1746 ) {
1747 return 'mw.loader.addSource('
1748 . $context->encodeJson( $sources )
1749 . ');';
1750 }
1751
1758 public static function makeLoaderConditionalScript( $script ) {
1759 // Adds a function to lazy-created RLQ
1760 return '(RLQ=window.RLQ||[]).push(function(){' .
1761 trim( $script ) . '});';
1762 }
1763
1772 public static function makeInlineCodeWithModule( $modules, $script ) {
1773 // Adds an array to lazy-created RLQ
1774 return '(RLQ=window.RLQ||[]).push(['
1775 . self::encodeJsonForScript( $modules ) . ','
1776 . 'function(){' . trim( $script ) . '}'
1777 . ']);';
1778 }
1779
1790 public static function makeInlineScript( $script, $nonce = null ) {
1791 $js = self::makeLoaderConditionalScript( $script );
1792 return new WrappedString(
1793 Html::inlineScript( $js ),
1794 "<script>(RLQ=window.RLQ||[]).push(function(){",
1795 '});</script>'
1796 );
1797 }
1798
1807 public static function makeConfigSetScript( array $configuration ) {
1808 $json = self::encodeJsonForScript( $configuration );
1809 if ( $json === false ) {
1810 $e = new LogicException(
1811 'JSON serialization of config data failed. ' .
1812 'This usually means the config data is not valid UTF-8.'
1813 );
1815 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1816 }
1817 return "mw.config.set($json);";
1818 }
1819
1833 public static function makePackedModulesString( array $modules ) {
1834 $moduleMap = []; // [ prefix => [ suffixes ] ]
1835 foreach ( $modules as $module ) {
1836 $pos = strrpos( $module, '.' );
1837 $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1838 $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1839 $moduleMap[$prefix][] = $suffix;
1840 }
1841
1842 $arr = [];
1843 foreach ( $moduleMap as $prefix => $suffixes ) {
1844 $p = $prefix === '' ? '' : $prefix . '.';
1845 $arr[] = $p . implode( ',', $suffixes );
1846 }
1847 return implode( '|', $arr );
1848 }
1849
1861 public static function expandModuleNames( $modules ) {
1862 $retval = [];
1863 $exploded = explode( '|', $modules );
1864 foreach ( $exploded as $group ) {
1865 if ( strpos( $group, ',' ) === false ) {
1866 // This is not a set of modules in foo.bar,baz notation
1867 // but a single module
1868 $retval[] = $group;
1869 continue;
1870 }
1871 // This is a set of modules in foo.bar,baz notation
1872 $pos = strrpos( $group, '.' );
1873 if ( $pos === false ) {
1874 // Prefixless modules, i.e. without dots
1875 $retval = array_merge( $retval, explode( ',', $group ) );
1876 continue;
1877 }
1878 // We have a prefix and a bunch of suffixes
1879 $prefix = substr( $group, 0, $pos ); // 'foo'
1880 $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1881 foreach ( $suffixes as $suffix ) {
1882 $retval[] = "$prefix.$suffix";
1883 }
1884 }
1885 return $retval;
1886 }
1887
1898 public static function inDebugMode() {
1899 if ( self::$debugMode === null ) {
1900 global $wgRequest;
1901
1902 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1903 MainConfigNames::ResourceLoaderDebug );
1904 $str = $wgRequest->getRawVal( 'debug',
1905 $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' )
1906 );
1907 self::$debugMode = Context::debugFromString( $str );
1908 }
1909 return self::$debugMode;
1910 }
1911
1922 public static function clearCache() {
1923 self::$debugMode = null;
1924 }
1925
1935 public function createLoaderURL( $source, Context $context,
1936 array $extraQuery = []
1937 ) {
1938 $query = self::createLoaderQuery( $context, $extraQuery );
1939 $script = $this->getLoadScript( $source );
1940
1941 return wfAppendQuery( $script, $query );
1942 }
1943
1953 protected static function createLoaderQuery(
1954 Context $context, array $extraQuery = []
1955 ) {
1956 return self::makeLoaderQuery(
1957 $context->getModules(),
1958 $context->getLanguage(),
1959 $context->getSkin(),
1960 $context->getUser(),
1961 $context->getVersion(),
1962 $context->getDebug(),
1963 $context->getOnly(),
1964 $context->getRequest()->getBool( 'printable' ),
1965 null,
1966 $extraQuery
1967 );
1968 }
1969
1986 public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1987 $version = null, $debug = Context::DEBUG_OFF, $only = null,
1988 $printable = false, $handheld = null, array $extraQuery = []
1989 ) {
1990 $query = [
1991 'modules' => self::makePackedModulesString( $modules ),
1992 ];
1993 // Keep urls short by omitting query parameters that
1994 // match the defaults assumed by Context.
1995 // Note: This relies on the defaults either being insignificant or forever constant,
1996 // as otherwise cached urls could change in meaning when the defaults change.
1997 if ( $lang !== Context::DEFAULT_LANG ) {
1998 $query['lang'] = $lang;
1999 }
2000 if ( $skin !== Context::DEFAULT_SKIN ) {
2001 $query['skin'] = $skin;
2002 }
2003 if ( $debug !== Context::DEBUG_OFF ) {
2004 $query['debug'] = strval( $debug );
2005 }
2006 if ( $user !== null ) {
2007 $query['user'] = $user;
2008 }
2009 if ( $version !== null ) {
2010 $query['version'] = $version;
2011 }
2012 if ( $only !== null ) {
2013 $query['only'] = $only;
2014 }
2015 if ( $printable ) {
2016 $query['printable'] = 1;
2017 }
2018 foreach ( $extraQuery as $name => $value ) {
2019 $query[$name] = $value;
2020 }
2021
2022 // Make queries uniform in order
2023 ksort( $query );
2024 return $query;
2025 }
2026
2036 public static function isValidModuleName( $moduleName ) {
2037 $len = strlen( $moduleName );
2038 return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
2039 }
2040
2051 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2052 global $IP;
2053 // When called from the installer, it is possible that a required PHP extension
2054 // is missing (at least for now; see T49564). If this is the case, throw an
2055 // exception (caught by the installer) to prevent a fatal error later on.
2056 if ( !class_exists( Less_Parser::class ) ) {
2057 throw new RuntimeException( 'MediaWiki requires the less.php parser' );
2058 }
2059
2060 $importDirs[] = "$IP/resources/src/mediawiki.less";
2061
2062 $parser = new Less_Parser;
2063 $parser->ModifyVars( $vars );
2064 $parser->SetOption( 'relativeUrls', false );
2065
2066 // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2067 $formattedImportDirs = array_fill_keys( $importDirs, '' );
2068 // Add a callback to the import dirs array for path remapping
2069 $formattedImportDirs[] = static function ( $path ) {
2070 global $IP;
2071 $importMap = [
2072 '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
2073 'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
2074 '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
2075 throw new RuntimeException(
2076 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2077 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2078 );
2079 }
2080 ];
2081 foreach ( $importMap as $importPath => $substPath ) {
2082 if ( str_starts_with( $path, $importPath ) ) {
2083 $restOfPath = substr( $path, strlen( $importPath ) );
2084 if ( is_callable( $substPath ) ) {
2085 $resolvedPath = call_user_func( $substPath, $restOfPath );
2086 } else {
2087 $filePath = $substPath . $restOfPath;
2088
2089 $resolvedPath = null;
2090 if ( file_exists( $filePath ) ) {
2091 $resolvedPath = $filePath;
2092 } elseif ( file_exists( "$filePath.less" ) ) {
2093 $resolvedPath = "$filePath.less";
2094 }
2095 }
2096
2097 if ( $resolvedPath !== null ) {
2098 return [
2099 Less_Environment::normalizePath( $resolvedPath ),
2100 Less_Environment::normalizePath( dirname( $path ) )
2101 ];
2102 } else {
2103 break;
2104 }
2105 }
2106 }
2107 return [ null, null ];
2108 };
2109 $parser->SetImportDirs( $formattedImportDirs );
2110
2111 return $parser;
2112 }
2113
2127 public function expandUrl( string $base, string $url ): string {
2128 // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2129 $isProtoRelative = strpos( $base, '//' ) === 0;
2130 if ( $isProtoRelative ) {
2131 $base = "https:$base";
2132 }
2133 // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2134 $baseUrl = new Net_URL2( $base );
2135 $ret = $baseUrl->resolve( $url );
2136 if ( $isProtoRelative ) {
2137 $ret->setScheme( false );
2138 }
2139 return $ret->getURL();
2140 }
2141
2159 public static function filter( $filter, $data, array $options = [] ) {
2160 if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2161 return $data;
2162 }
2163
2164 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2165 return self::applyFilter( $filter, $data ) ?? $data;
2166 }
2167
2168 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2170
2171 $key = $cache->makeGlobalKey(
2172 'resourceloader-filter',
2173 $filter,
2174 self::CACHE_VERSION,
2175 md5( $data )
2176 );
2177
2178 $status = 'hit';
2179 $incKey = "resourceloader_cache.$filter.$status";
2180 $result = $cache->getWithSetCallback(
2181 $key,
2182 BagOStuff::TTL_DAY,
2183 static function () use ( $filter, $data, &$incKey, &$status ) {
2184 $status = 'miss';
2185 $incKey = "resourceloader_cache.$filter.$status";
2186 return self::applyFilter( $filter, $data );
2187 }
2188 );
2189 $statsFactory->getCounter( 'resourceloader_cache_total' )
2190 ->setLabel( 'type', $filter )
2191 ->setLabel( 'status', $status )
2192 ->copyToStatsdAt( [ $incKey ] )
2193 ->increment();
2194
2195 // Use $data on cache failure
2196 return $result ?? $data;
2197 }
2198
2204 private static function applyFilter( $filter, $data ) {
2205 $data = trim( $data );
2206 if ( $data ) {
2207 try {
2208 $data = ( $filter === 'minify-css' )
2209 ? CSSMin::minify( $data )
2210 : JavaScriptMinifier::minify( $data );
2211 } catch ( TimeoutException $e ) {
2212 throw $e;
2213 } catch ( Exception $e ) {
2215 return null;
2216 }
2217 }
2218 return $data;
2219 }
2220
2232 public static function getUserDefaults(
2233 Context $context,
2234 HookContainer $hookContainer,
2235 UserOptionsLookup $userOptionsLookup
2236 ): array {
2237 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2238 $keysToExclude = [];
2239 $hookRunner = new HookRunner( $hookContainer );
2240 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2241 foreach ( $keysToExclude as $excludedKey ) {
2242 unset( $defaultOptions[ $excludedKey ] );
2243 }
2244 return $defaultOptions;
2245 }
2246
2255 public static function getSiteConfigSettings(
2256 Context $context, Config $conf
2257 ): array {
2258 $services = MediaWikiServices::getInstance();
2259 // Namespace related preparation
2260 // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2261 // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2262 $contLang = $services->getContentLanguage();
2263 $namespaceIds = $contLang->getNamespaceIds();
2264 $caseSensitiveNamespaces = [];
2265 $nsInfo = $services->getNamespaceInfo();
2266 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2267 $namespaceIds[$contLang->lc( $name )] = $index;
2268 if ( !$nsInfo->isCapitalized( $index ) ) {
2269 $caseSensitiveNamespaces[] = $index;
2270 }
2271 }
2272
2273 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2274
2275 // Build list of variables
2276 $skin = $context->getSkin();
2277
2278 // Start of supported and stable config vars (for use by extensions/gadgets).
2279 $vars = [
2280 'debug' => $context->getDebug(),
2281 'skin' => $skin,
2282 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2283 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2284 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2285 'wgScript' => $conf->get( MainConfigNames::Script ),
2286 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2287 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2288 'wgServer' => $conf->get( MainConfigNames::Server ),
2289 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2290 'wgUserLanguage' => $context->getLanguage(),
2291 'wgContentLanguage' => $contLang->getCode(),
2292 'wgVersion' => MW_VERSION,
2293 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2294 'wgNamespaceIds' => $namespaceIds,
2295 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2296 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2297 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2298 'wgWikiID' => WikiMap::getCurrentWikiId(),
2299 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2300 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2301 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2302 ];
2303 // End of stable config vars.
2304
2305 // Internal variables for use by MediaWiki core and/or ResourceLoader.
2306 $vars += [
2307 // @internal For mediawiki.widgets
2308 'wgUrlProtocols' => wfUrlProtocols(),
2309 // @internal For mediawiki.page.watch
2310 // Force object to avoid "empty" associative array from
2311 // becoming [] instead of {} in JS (T36604)
2312 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2313 // @internal For mediawiki.language
2314 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2315 // @internal For mediawiki.Title
2316 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2317 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2318 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2319 ];
2320
2321 ( new HookRunner( $services->getHookContainer() ) )
2322 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2323
2324 return $vars;
2325 }
2326
2331 public function getErrors() {
2332 return $this->errors;
2333 }
2334}
const CACHE_ANYTHING
Definition Defines.php:85
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:36
wfUrlProtocols( $includeProtocolRelative=true)
Returns a partial regular expression of recognized URL protocols, e.g.
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.
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:98
global $wgRequest
Definition Setup.php:415
array $params
The job parameters.
const MW_ENTRY_POINT
Definition api.php:35
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:85
Load JSON files, and uses a Processor to extract information.
Simple store for keeping values in an associative array for the current process.
Handler class for MWExceptions.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
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.
Defer callable updates to run later in the PHP process.
A wrapper class which causes Html::encodeJsVar() and Html::encodeJsCall() (as well as their Xml::* co...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A class containing constants representing the names of configuration variables.
const ResourceLoaderEnableSourceMapLinks
Name constant for the ResourceLoaderEnableSourceMapLinks setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Class for tracking request-level classification information for profiling/stats/logging.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode.
Definition Context.php:493
getImageObj()
If this is a request for an image, get the Image object.
Definition Context.php:374
This class generates message blobs for use by ResourceLoader.
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:49
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:663
static getVary(Context $context)
Get vary string.
Definition Module.php:1143
shouldEmbedModule(Context $context)
Check whether this module should be embedded rather than linked.
Definition Module.php:1058
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=[])
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.
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, array $moduleNames)
Batched version of WikiModule::getTitleInfo.
Represents a title within MediaWiki.
Definition Title.php:78
Provides access to user options.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Functions to get cache objects.
static getLocalServerInstance( $fallback=CACHE_NONE)
Factory function for CACHE_ACCEL (referenced from configuration)
Track per-module dependency file paths that are expensive to mass compute.
Track per-module file dependencies in object cache via BagOStuff.
StatsFactory Implementation.
getCounter(string $name)
Makes a new CounterMetric or fetches one from cache.
Interface for configuration instances.
Definition Config.php:32
$source
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...
$header