MediaWiki master
ResourceLoader.php
Go to the documentation of this file.
1<?php
24
25use BagOStuff;
26use Exception;
29use HttpStatus;
31use InvalidArgumentException;
32use Less_Environment;
33use Less_Parser;
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;
72use Wikimedia\Timestamp\ConvertibleTimestamp;
73use Wikimedia\WrappedString;
74
95class ResourceLoader implements LoggerAwareInterface {
97 public const CACHE_VERSION = 9;
99 public const FILTER_NOMIN = '/*@nomin*/';
100
102 private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
104 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
106 private const MAXAGE_RECOVER = 60;
107
109 protected static $debugMode = null;
110
112 private $config;
114 private $blobStore;
116 private $depStore;
118 private $logger;
120 private $hookContainer;
122 private $srvCache;
124 private $stats;
126 private $maxageVersioned;
128 private $maxageUnversioned;
129
131 private $modules = [];
133 private $moduleInfos = [];
135 private $testModuleNames = [];
137 private $sources = [];
139 protected $errors = [];
144 protected $extraHeaders = [];
146 private $depStoreUpdateBuffer = [];
151 private $moduleSkinStyles = [];
152
173 public function __construct(
174 Config $config,
175 LoggerInterface $logger = null,
176 DependencyStore $tracker = null,
177 array $params = []
178 ) {
179 $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
180 $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
181
182 $this->config = $config;
183 $this->logger = $logger ?: new NullLogger();
184
185 $services = MediaWikiServices::getInstance();
186 $this->hookContainer = $services->getHookContainer();
187
188 $this->srvCache = $services->getLocalServerObjectCache();
189 $this->stats = $services->getStatsdDataFactory();
190
191 // Add 'local' source first
192 $this->addSource( 'local', $params['loadScript'] ?? '/load.php' );
193
194 // Special module that always exists
195 $this->register( 'startup', [ 'class' => StartUpModule::class ] );
196
197 $this->setMessageBlobStore(
198 new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
199 );
200
201 $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
202 $this->setDependencyStore( $tracker );
203 }
204
208 public function getConfig() {
209 return $this->config;
210 }
211
216 public function setLogger( LoggerInterface $logger ) {
217 $this->logger = $logger;
218 }
219
224 public function getLogger() {
225 return $this->logger;
226 }
227
232 public function getMessageBlobStore() {
233 return $this->blobStore;
234 }
235
240 public function setMessageBlobStore( MessageBlobStore $blobStore ) {
241 $this->blobStore = $blobStore;
242 }
243
248 public function setDependencyStore( DependencyStore $tracker ) {
249 $this->depStore = $tracker;
250 }
251
256 public function setModuleSkinStyles( array $moduleSkinStyles ) {
257 $this->moduleSkinStyles = $moduleSkinStyles;
258 }
259
271 public function register( $name, array $info = null ) {
272 // Allow multiple modules to be registered in one call
273 $registrations = is_array( $name ) ? $name : [ $name => $info ];
274 foreach ( $registrations as $name => $info ) {
275 // Warn on duplicate registrations
276 if ( isset( $this->moduleInfos[$name] ) ) {
277 // A module has already been registered by this name
278 $this->logger->warning(
279 'ResourceLoader duplicate registration warning. ' .
280 'Another module has already been registered as ' . $name
281 );
282 }
283
284 // Check validity
285 if ( !self::isValidModuleName( $name ) ) {
286 throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
287 . "see ResourceLoader::isValidModuleName()" );
288 }
289 if ( !is_array( $info ) ) {
290 throw new InvalidArgumentException(
291 'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
292 );
293 }
294
295 // Attach module
296 $this->moduleInfos[$name] = $info;
297 }
298 }
299
304 public function registerTestModules(): void {
305 $extRegistry = ExtensionRegistry::getInstance();
306 $testModules = $extRegistry->getAttribute( 'QUnitTestModules' );
307
308 $testModuleNames = [];
309 foreach ( $testModules as $name => &$module ) {
310 // Turn any single-module dependency into an array
311 if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
312 $module['dependencies'] = [ $module['dependencies'] ];
313 }
314
315 // Ensure the testrunner loads before any tests
316 $module['dependencies'][] = 'mediawiki.qunit-testrunner';
317
318 // Keep track of the modules to load on SpecialJavaScriptTest
319 $testModuleNames[] = $name;
320 }
321
322 // Core test modules (their names have further precedence).
323 $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
324 $testModuleNames[] = 'test.MediaWiki';
325
326 $this->register( $testModules );
327 $this->testModuleNames = $testModuleNames;
328 }
329
340 public function addSource( $sources, $loadUrl = null ) {
341 if ( !is_array( $sources ) ) {
342 $sources = [ $sources => $loadUrl ];
343 }
344 foreach ( $sources as $id => $source ) {
345 // Disallow duplicates
346 if ( isset( $this->sources[$id] ) ) {
347 throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
348 }
349
350 // Support: MediaWiki 1.24 and earlier
351 if ( is_array( $source ) ) {
352 if ( !isset( $source['loadScript'] ) ) {
353 throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
354 }
355 $source = $source['loadScript'];
356 }
357
358 $this->sources[$id] = $source;
359 }
360 }
361
365 public function getModuleNames() {
366 return array_keys( $this->moduleInfos );
367 }
368
376 public function getTestSuiteModuleNames() {
377 return $this->testModuleNames;
378 }
379
387 public function isModuleRegistered( $name ) {
388 return isset( $this->moduleInfos[$name] );
389 }
390
402 public function getModule( $name ) {
403 if ( !isset( $this->modules[$name] ) ) {
404 if ( !isset( $this->moduleInfos[$name] ) ) {
405 // No such module
406 return null;
407 }
408 // Construct the requested module object
409 $info = $this->moduleInfos[$name];
410 if ( isset( $info['factory'] ) ) {
412 $object = call_user_func( $info['factory'], $info );
413 } else {
414 $class = $info['class'] ?? FileModule::class;
416 $object = new $class( $info );
417 }
418 $object->setConfig( $this->getConfig() );
419 $object->setLogger( $this->logger );
420 $object->setHookContainer( $this->hookContainer );
421 $object->setName( $name );
422 $object->setDependencyAccessCallbacks(
423 [ $this, 'loadModuleDependenciesInternal' ],
424 [ $this, 'saveModuleDependenciesInternal' ]
425 );
426 $object->setSkinStylesOverride( $this->moduleSkinStyles );
427 $this->modules[$name] = $object;
428 }
429
430 return $this->modules[$name];
431 }
432
439 public function preloadModuleInfo( array $moduleNames, Context $context ) {
440 // Load all tracked indirect file dependencies for the modules
441 $vary = Module::getVary( $context );
442 $entitiesByModule = [];
443 foreach ( $moduleNames as $moduleName ) {
444 $entitiesByModule[$moduleName] = "$moduleName|$vary";
445 }
446 $depsByEntity = $this->depStore->retrieveMulti(
447 self::RL_DEP_STORE_PREFIX,
448 $entitiesByModule
449 );
450 // Inject the indirect file dependencies for all the modules
451 foreach ( $moduleNames as $moduleName ) {
452 $module = $this->getModule( $moduleName );
453 if ( $module ) {
454 $entity = $entitiesByModule[$moduleName];
455 $deps = $depsByEntity[$entity];
456 $paths = Module::expandRelativePaths( $deps['paths'] );
457 $module->setFileDependencies( $context, $paths );
458 }
459 }
460
461 // Batched version of WikiModule::getTitleInfo
462 $dbr = wfGetDB( DB_REPLICA );
463 WikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
464
465 // Prime in-object cache for message blobs for modules with messages
466 $modulesWithMessages = [];
467 foreach ( $moduleNames as $moduleName ) {
468 $module = $this->getModule( $moduleName );
469 if ( $module && $module->getMessages() ) {
470 $modulesWithMessages[$moduleName] = $module;
471 }
472 }
473 // Prime in-object cache for message blobs for modules with messages
474 $lang = $context->getLanguage();
475 $store = $this->getMessageBlobStore();
476 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
477 foreach ( $blobs as $moduleName => $blob ) {
478 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
479 }
480 }
481
488 public function loadModuleDependenciesInternal( $moduleName, $variant ) {
489 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
490
491 return Module::expandRelativePaths( $deps['paths'] );
492 }
493
501 public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
502 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
503 $entity = "$moduleName|$variant";
504
505 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
506 // Dependency store needs to be updated with the new path list
507 if ( $paths ) {
508 $deps = $this->depStore->newEntityDependencies( $paths, time() );
509 $this->depStoreUpdateBuffer[$entity] = $deps;
510 } else {
511 $this->depStoreUpdateBuffer[$entity] = null;
512 }
513 }
514
515 // If paths were unchanged, leave the dependency store unchanged also.
516 // The entry will eventually expire, after which we will briefly issue an incomplete
517 // version hash for a 5-min startup window, the module then recomputes and rediscovers
518 // the paths and arrive at the same module version hash once again. It will churn
519 // part of the browser cache once, for clients connecting during that window.
520
521 if ( !$hasPendingUpdate ) {
522 DeferredUpdates::addCallableUpdate( function () {
523 $updatesByEntity = $this->depStoreUpdateBuffer;
524 $this->depStoreUpdateBuffer = [];
525 $cache = ObjectCache::getLocalClusterInstance();
526
527 $scopeLocks = [];
528 $depsByEntity = [];
529 $entitiesUnreg = [];
530 foreach ( $updatesByEntity as $entity => $update ) {
531 $lockKey = $cache->makeKey( 'rl-deps', $entity );
532 $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
533 if ( !$scopeLocks[$entity] ) {
534 // avoid duplicate write request slams (T124649)
535 // the lock must be specific to the current wiki (T247028)
536 continue;
537 }
538 if ( $update === null ) {
539 $entitiesUnreg[] = $entity;
540 } else {
541 $depsByEntity[$entity] = $update;
542 }
543 }
544
545 $ttl = self::RL_MODULE_DEP_TTL;
546 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
547 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
548 } );
549 }
550 }
551
557 public function getSources() {
558 return $this->sources;
559 }
560
569 public function getLoadScript( $source ) {
570 if ( !isset( $this->sources[$source] ) ) {
571 throw new UnexpectedValueException( "Unknown source '$source'" );
572 }
573 return $this->sources[$source];
574 }
575
579 public const HASH_LENGTH = 5;
580
643 public static function makeHash( $value ) {
644 $hash = hash( 'fnv132', $value );
645 // The base_convert will pad it (if too short),
646 // then substr() will trim it (if too long).
647 return substr(
648 \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
649 0,
650 self::HASH_LENGTH
651 );
652 }
653
663 public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
664 MWExceptionHandler::logException( $e );
665 $this->logger->warning(
666 $msg,
667 $context + [ 'exception' => $e ]
668 );
669 $this->errors[] = self::formatExceptionNoComment( $e );
670 }
671
680 public function getCombinedVersion( Context $context, array $moduleNames ) {
681 if ( !$moduleNames ) {
682 return '';
683 }
684 $hashes = [];
685 foreach ( $moduleNames as $module ) {
686 try {
687 $hash = $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 $hash = '';
700 }
701 $hashes[] = $hash;
702 }
703 return self::makeHash( implode( '', $hashes ) );
704 }
705
720 public function makeVersionQuery( Context $context, array $modules ) {
721 // As of MediaWiki 1.28, the server and client use the same algorithm for combining
722 // version hashes. There is no technical reason for this to be same, and for years the
723 // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
724 // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
725 // query parameter), then this method must continue to match the JS one.
726 $filtered = [];
727 foreach ( $modules as $name ) {
728 if ( !$this->getModule( $name ) ) {
729 // If a versioned request contains a missing module, the version is a mismatch
730 // as the client considered a module (and version) we don't have.
731 return '';
732 }
733 $filtered[] = $name;
734 }
735 return $this->getCombinedVersion( $context, $filtered );
736 }
737
743 public function respond( Context $context ) {
744 // Buffer output to catch warnings. Normally we'd use ob_clean() on the
745 // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
746 // is used: ob_clean() will clear the GZIP header in that case and it won't come
747 // back for subsequent output, resulting in invalid GZIP. So we have to wrap
748 // the whole thing in our own output buffer to be sure the active buffer
749 // doesn't use ob_gzhandler.
750 // See https://bugs.php.net/bug.php?id=36514
751 ob_start();
752
753 $this->errors = [];
754 $responseTime = $this->measureResponseTime();
755 ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' );
756
757 // Find out which modules are missing and instantiate the others
758 $modules = [];
759 $missing = [];
760 foreach ( $context->getModules() as $name ) {
761 $module = $this->getModule( $name );
762 if ( $module ) {
763 // Do not allow private modules to be loaded from the web.
764 // This is a security issue, see T36907.
765 if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
766 // Not a serious error, just means something is trying to access it (T101806)
767 $this->logger->debug( "Request for private module '$name' denied" );
768 $this->errors[] = "Cannot build private module \"$name\"";
769 continue;
770 }
771 $modules[$name] = $module;
772 } else {
773 $missing[] = $name;
774 }
775 }
776
777 try {
778 // Preload for getCombinedVersion() and for batch makeModuleResponse()
779 $this->preloadModuleInfo( array_keys( $modules ), $context );
780 } catch ( TimeoutException $e ) {
781 throw $e;
782 } catch ( Exception $e ) {
783 $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
784 }
785
786 // Combine versions to propagate cache invalidation
787 $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
788
789 // See RFC 2616 § 3.11 Entity Tags
790 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
791 $etag = 'W/"' . $versionHash . '"';
792
793 // Try the client-side cache first
794 if ( $this->tryRespondNotModified( $context, $etag ) ) {
795 return; // output handled (buffers cleared)
796 }
797
798 if ( $context->isSourceMap() ) {
799 // In source map mode, a version mismatch should be a 404
800 if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) {
801 ob_end_clean();
802 $this->sendSourceMapVersionMismatch( $versionHash );
803 return;
804 }
805 // No source maps for images, only=styles requests, or debug mode
806 if ( $context->getImage()
807 || $context->getOnly() === 'styles'
808 || $context->getDebug()
809 ) {
810 ob_end_clean();
811 $this->sendSourceMapTypeNotImplemented();
812 return;
813 }
814 }
815 // Emit source map header if supported (inverse of the above check)
817 && !$context->getImageObj()
818 && !$context->isSourceMap()
819 && $context->shouldIncludeScripts()
820 && !$context->getDebug()
821 ) {
822 $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
823 }
824
825 // Generate a response
826 $response = $this->makeModuleResponse( $context, $modules, $missing );
827
828 // Capture any PHP warnings from the output buffer and append them to the
829 // error list if we're in debug mode.
830 if ( $context->getDebug() ) {
831 $warnings = ob_get_contents();
832 if ( strlen( $warnings ) ) {
833 $this->errors[] = $warnings;
834 }
835 }
836
837 $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
838
839 // Remove the output buffer and output the response
840 ob_end_clean();
841
842 if ( $context->getImageObj() && $this->errors ) {
843 // We can't show both the error messages and the response when it's an image.
844 $response = implode( "\n\n", $this->errors );
845 } elseif ( $this->errors ) {
846 $errorText = implode( "\n\n", $this->errors );
847 $errorResponse = self::makeComment( $errorText );
848 if ( $context->shouldIncludeScripts() ) {
849 $errorResponse .= 'if (window.console && console.error) { console.error('
850 . $context->encodeJson( $errorText )
851 . "); }\n";
852 // Append the error info to the response
853 // We used to prepend it, but that would corrupt the source map
854 $response .= $errorResponse;
855 } else {
856 // For styles we can still prepend
857 $response = $errorResponse . $response;
858 }
859 }
860
861 // @phan-suppress-next-line SecurityCheck-XSS
862 echo $response;
863 }
864
869 protected function measureResponseTime() {
870 $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
871 return new ScopedCallback( function () use ( $statStart ) {
872 $statTiming = microtime( true ) - $statStart;
873 $this->stats->timing( 'resourceloader.responseTime', $statTiming * 1000 );
874 } );
875 }
876
887 protected function sendResponseHeaders(
888 Context $context, $etag, $errors, array $extra = []
889 ): void {
890 HeaderCallback::warnIfHeadersSent();
891
892 if ( $errors ) {
893 $maxage = self::MAXAGE_RECOVER;
894 } elseif (
895 $context->getVersion() !== null
896 && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
897 ) {
898 // If we need to self-correct, set a very short cache expiry
899 // to basically just debounce CDN traffic. This applies to:
900 // - Internal errors, e.g. due to misconfiguration.
901 // - Version mismatch, e.g. due to deployment race (T117587, T47877).
902 $this->logger->debug( 'Client and server registry version out of sync' );
903 $maxage = self::MAXAGE_RECOVER;
904 } elseif ( $context->getVersion() === null ) {
905 // Resources that can't set a version, should have their updates propagate to
906 // clients quickly. This applies to shared resources linked from HTML, such as
907 // the startup module and stylesheets.
908 $maxage = $this->maxageUnversioned;
909 } else {
910 // When a version is set, use a long expiry because changes
911 // will naturally miss the cache by using a different URL.
912 $maxage = $this->maxageVersioned;
913 }
914 if ( $context->getImageObj() ) {
915 // Output different headers if we're outputting textual errors.
916 if ( $errors ) {
917 header( 'Content-Type: text/plain; charset=utf-8' );
918 } else {
919 $context->getImageObj()->sendResponseHeaders( $context );
920 }
921 } elseif ( $context->isSourceMap() ) {
922 header( 'Content-Type: application/json' );
923 } elseif ( $context->getOnly() === 'styles' ) {
924 header( 'Content-Type: text/css; charset=utf-8' );
925 header( 'Access-Control-Allow-Origin: *' );
926 } else {
927 header( 'Content-Type: text/javascript; charset=utf-8' );
928 }
929 // See RFC 2616 § 14.19 ETag
930 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
931 header( 'ETag: ' . $etag );
932 if ( $context->getDebug() ) {
933 // Do not cache debug responses
934 header( 'Cache-Control: private, no-cache, must-revalidate' );
935 } else {
936 // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
937 // the background instead of blocking the next page load (eg. startup module, or CSS).
938 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
939 ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
940 : ''
941 );
942 header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
943 header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
944 }
945 foreach ( $extra as $header ) {
946 header( $header );
947 }
948 }
949
960 protected function tryRespondNotModified( Context $context, $etag ) {
961 // See RFC 2616 § 14.26 If-None-Match
962 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
963 $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
964 // Never send 304s in debug mode
965 if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
966 // There's another bug in ob_gzhandler (see also the comment at
967 // the top of this function) that causes it to gzip even empty
968 // responses, meaning it's impossible to produce a truly empty
969 // response (because the gzip header is always there). This is
970 // a problem because 304 responses have to be completely empty
971 // per the HTTP spec, and Firefox behaves buggily when they're not.
972 // See also https://bugs.php.net/bug.php?id=51579
973 // To work around this, we tear down all output buffering before
974 // sending the 304.
975 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
976
977 HttpStatus::header( 304 );
978
979 $this->sendResponseHeaders( $context, $etag, false );
980 return true;
981 }
982 return false;
983 }
984
992 private function getSourceMapUrl( Context $context, $version ) {
993 return $this->createLoaderURL( 'local', $context, [
994 'sourcemap' => '1',
995 'version' => $version
996 ] );
997 }
998
1004 private function sendSourceMapVersionMismatch( $currentVersion ) {
1005 HttpStatus::header( 404 );
1006 header( 'Content-Type: text/plain; charset=utf-8' );
1007 header( 'X-Content-Type-Options: nosniff' );
1008 echo "Can't deliver a source map for the requested version " .
1009 "since the version is now '$currentVersion'\n";
1010 }
1011
1016 private function sendSourceMapTypeNotImplemented() {
1017 HttpStatus::header( 404 );
1018 header( 'Content-Type: text/plain; charset=utf-8' );
1019 header( 'X-Content-Type-Options: nosniff' );
1020 echo "Can't make a source map for this content type\n";
1021 }
1022
1031 public static function makeComment( $text ) {
1032 $encText = str_replace( '*/', '* /', $text );
1033 return "/*\n$encText\n*/\n";
1034 }
1035
1042 public static function formatException( Throwable $e ) {
1043 return self::makeComment( self::formatExceptionNoComment( $e ) );
1044 }
1045
1053 protected static function formatExceptionNoComment( Throwable $e ) {
1054 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1055 return MWExceptionHandler::getPublicLogMessage( $e );
1056 }
1057
1058 return MWExceptionHandler::getLogMessage( $e ) .
1059 "\nBacktrace:\n" .
1060 MWExceptionHandler::getRedactedTraceAsString( $e );
1061 }
1062
1074 public function makeModuleResponse( Context $context,
1075 array $modules, array $missing = []
1076 ) {
1077 if ( $modules === [] && $missing === [] ) {
1078 return <<<MESSAGE
1079/* This file is the Web entry point for MediaWiki's ResourceLoader:
1080 <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1081 no modules were requested. Max made me put this here. */
1082MESSAGE;
1083 }
1084
1085 $image = $context->getImageObj();
1086 if ( $image ) {
1087 $data = $image->getImageData( $context );
1088 if ( $data === false ) {
1089 $data = '';
1090 $this->errors[] = 'Image generation failed';
1091 }
1092 return $data;
1093 }
1094
1095 $states = [];
1096 foreach ( $missing as $name ) {
1097 $states[$name] = 'missing';
1098 }
1099
1100 $only = $context->getOnly();
1101 $debug = (bool)$context->getDebug();
1102 if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1103 $indexMap = new IndexMap;
1104 } else {
1105 $indexMap = null;
1106 }
1107
1108 $out = '';
1109 foreach ( $modules as $name => $module ) {
1110 try {
1111 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1112 if ( $indexMap ) {
1113 $indexMap->addEncodedMap( $response, $offset );
1114 } else {
1115 $out .= $response;
1116 }
1117 } catch ( TimeoutException $e ) {
1118 throw $e;
1119 } catch ( Exception $e ) {
1120 $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1121
1122 // Respond to client with error-state instead of module implementation
1123 $states[$name] = 'error';
1124 unset( $modules[$name] );
1125 }
1126 }
1127
1128 // Update module states
1129 if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1130 if ( $modules && $only === 'scripts' ) {
1131 // Set the state of modules loaded as only scripts to ready as
1132 // they don't have an mw.loader.impl wrapper that sets the state
1133 foreach ( $modules as $name => $module ) {
1134 $states[$name] = 'ready';
1135 }
1136 }
1137
1138 // Set the state of modules we didn't respond to with mw.loader.impl
1139 if ( $states && !$context->isSourceMap() ) {
1140 $stateScript = self::makeLoaderStateScript( $context, $states );
1141 if ( !$debug ) {
1142 $stateScript = self::filter( 'minify-js', $stateScript );
1143 }
1144 // Use a linebreak between module script and state script (T162719)
1145 $out = self::ensureNewline( $out ) . $stateScript;
1146 }
1147 } elseif ( $states ) {
1148 $this->errors[] = 'Problematic modules: '
1149 // Silently ignore invalid UTF-8 injected via 'modules' query
1150 // Don't issue server-side warnings for client errors. (T331641)
1151 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1152 . @$context->encodeJson( $states );
1153 }
1154
1155 if ( $indexMap ) {
1156 return $indexMap->getMap();
1157 } else {
1158 return $out;
1159 }
1160 }
1161
1170 private function getOneModuleResponse( Context $context, $name, Module $module ) {
1171 $only = $context->getOnly();
1172 // Important: Do not cache minifications of embedded modules
1173 // This is especially for the private 'user.options' module,
1174 // which varies on every pageview and would explode the cache (T84960)
1175 $shouldCache = !$module->shouldEmbedModule( $context );
1176 if ( $only === 'styles' ) {
1177 $minifier = new IdentityMinifierState;
1178 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1179 return [
1180 self::filter( 'minify-css', $minifier->getMinifiedOutput(),
1181 [ 'cache' => $shouldCache ] ),
1182 null
1183 ];
1184 }
1185
1186 $minifier = new IdentityMinifierState;
1187 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1188 $plainContent = $minifier->getMinifiedOutput();
1189 if ( $context->getDebug() ) {
1190 return [ $plainContent, null ];
1191 }
1192
1193 $isHit = true;
1194 $callback = function () use ( $context, $name, $module, &$isHit ) {
1195 $isHit = false;
1196 if ( $context->isSourceMap() ) {
1197 $minifier = ( new JavaScriptMapperState )
1198 ->outputFile( $this->createLoaderURL( 'local', $context, [
1199 'modules' => self::makePackedModulesString( $context->getModules() ),
1200 'only' => $context->getOnly()
1201 ] ) );
1202 } else {
1203 $minifier = new JavaScriptMinifierState;
1204 }
1205 // We only need to add one set of headers, and we did that for the identity response
1206 $discardedHeaders = null;
1207 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1208 if ( $context->isSourceMap() ) {
1209 $sourceMap = $minifier->getRawSourceMap();
1210 $generated = $minifier->getMinifiedOutput();
1211 $offset = IndexMapOffset::newFromText( $generated );
1212 return [ $sourceMap, $offset->toArray() ];
1213 } else {
1214 return [ $minifier->getMinifiedOutput(), null ];
1215 }
1216 };
1217
1218 if ( $shouldCache ) {
1219 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1220 $this->srvCache->makeGlobalKey(
1221 'resourceloader-mapped',
1222 self::CACHE_VERSION,
1223 $name,
1224 $context->isSourceMap() ? '1' : '0',
1225 md5( $plainContent )
1226 ),
1227 BagOStuff::TTL_DAY,
1228 $callback
1229 );
1230 $this->stats->increment( implode( '.', [
1231 "resourceloader_cache",
1232 $context->isSourceMap() ? 'map-js' : 'minify-js',
1233 $isHit ? 'hit' : 'miss'
1234 ] ) );
1235 } else {
1236 [ $response, $offsetArray ] = $callback();
1237 }
1238 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1239
1240 return [ $response, $offset ];
1241 }
1242
1253 private function addOneModuleResponse(
1254 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1255 ) {
1256 $only = $context->getOnly();
1257 $debug = (bool)$context->getDebug();
1258 $content = $module->getModuleContent( $context );
1259 $version = $module->getVersionHash( $context );
1260
1261 if ( $headers !== null && isset( $content['headers'] ) ) {
1262 $headers = array_merge( $headers, $content['headers'] );
1263 }
1264
1265 // Append output
1266 switch ( $only ) {
1267 case 'scripts':
1268 $scripts = $content['scripts'];
1269 if ( !is_array( $scripts ) ) {
1270 // Formerly scripts was usually a string, but now it is
1271 // normalized to an array by buildContent().
1272 throw new InvalidArgumentException( 'scripts must be an array' );
1273 }
1274 if ( isset( $scripts['plainScripts'] ) ) {
1275 // Add plain scripts
1276 $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1277 } elseif ( isset( $scripts['files'] ) ) {
1278 // Add implement call if any
1279 $this->addImplementScript(
1280 $minifier,
1281 $name,
1282 $version,
1283 $scripts,
1284 [],
1285 null,
1286 [],
1287 $content['deprecationWarning'] ?? null
1288 );
1289 }
1290 break;
1291 case 'styles':
1292 $styles = $content['styles'];
1293 // We no longer separate into media, they are all combined now with
1294 // custom media type groups into @media .. {} sections as part of the css string.
1295 // Module returns either an empty array or a numerical array with css strings.
1296 if ( isset( $styles['css'] ) ) {
1297 $minifier->addOutput( implode( '', $styles['css'] ) );
1298 }
1299 break;
1300 default:
1301 $scripts = $content['scripts'] ?? '';
1302 if ( ( $name === 'site' || $name === 'user' )
1303 && isset( $scripts['plainScripts'] )
1304 ) {
1305 // Legacy scripts that run in the global scope without a closure.
1306 // mw.loader.impl will use eval if scripts is a string.
1307 // Minify manually here, because general response minification is
1308 // not effective due it being a string literal, not a function.
1309 $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1310 if ( !$debug ) {
1311 $scripts = self::filter( 'minify-js', $scripts ); // T107377
1312 }
1313 }
1314 $this->addImplementScript(
1315 $minifier,
1316 $name,
1317 $version,
1318 $scripts,
1319 $content['styles'] ?? [],
1320 isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1321 $content['templates'] ?? [],
1322 $content['deprecationWarning'] ?? null
1323 );
1324 break;
1325 }
1326 $minifier->ensureNewline();
1327 }
1328
1335 public static function ensureNewline( $str ) {
1336 $end = substr( $str, -1 );
1337 if ( $end === false || $end === '' || $end === "\n" ) {
1338 return $str;
1339 }
1340 return $str . "\n";
1341 }
1342
1349 public function getModulesByMessage( $messageKey ) {
1350 $moduleNames = [];
1351 foreach ( $this->getModuleNames() as $moduleName ) {
1352 $module = $this->getModule( $moduleName );
1353 if ( in_array( $messageKey, $module->getMessages() ) ) {
1354 $moduleNames[] = $moduleName;
1355 }
1356 }
1357 return $moduleNames;
1358 }
1359
1381 private function addImplementScript( MinifierState $minifier,
1382 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1383 ) {
1384 $implementKey = "$moduleName@$version";
1385 // Plain functions are used instead of arrow functions to avoid
1386 // defeating lazy compilation on Chrome. (T343407)
1387 $minifier->addOutput( "mw.loader.impl(function(){return[" .
1388 Html::encodeJsVar( $implementKey ) . "," );
1389
1390 // Scripts
1391 if ( is_string( $scripts ) ) {
1392 // user/site script
1393 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1394 } elseif ( is_array( $scripts ) ) {
1395 if ( isset( $scripts['files'] ) ) {
1396 $minifier->addOutput(
1397 "{\"main\":" .
1398 Html::encodeJsVar( $scripts['main'] ) .
1399 ",\"files\":" );
1400 $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1401 $minifier->addOutput( "}" );
1402 } elseif ( isset( $scripts['plainScripts'] ) ) {
1403 if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1404 $minifier->addOutput( 'null' );
1405 } else {
1406 $minifier->addOutput( "function($,jQuery,require,module){" );
1407 $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1408 $minifier->addOutput( "}" );
1409 }
1410 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1411 // Array of URLs
1412 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1413 } else {
1414 throw new InvalidArgumentException( 'Invalid script array: ' .
1415 'must contain files, plainScripts or be an array of URLs' );
1416 }
1417 } else {
1418 throw new InvalidArgumentException( 'Script must be a string or array' );
1419 }
1420
1421 // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1422 // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1423 // of "{}". Force them to objects.
1424 $extraArgs = [
1425 (object)$styles,
1426 $messages ?? (object)[],
1427 (object)$templates,
1428 $deprecationWarning
1429 ];
1430 self::trimArray( $extraArgs );
1431 foreach ( $extraArgs as $arg ) {
1432 $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1433 }
1434 $minifier->addOutput( "];});" );
1435 }
1436
1447 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1448 $first = true;
1449 $minifier->addOutput( "{" );
1450 foreach ( $files as $fileName => $file ) {
1451 if ( $first ) {
1452 $first = false;
1453 } else {
1454 $minifier->addOutput( "," );
1455 }
1456 $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1457 $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1458 }
1459 $minifier->addOutput( "}" );
1460 }
1461
1471 private function addFileContent( MinifierState $minifier,
1472 $moduleName, $sourceType, $sourceIndex, array $file
1473 ) {
1474 $isScript = ( $file['type'] ?? 'script' ) === 'script';
1476 $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1477 if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1478 $url = $filePath->getRemotePath();
1479 } else {
1480 $ext = $isScript ? 'js' : 'json';
1481 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1482 ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1483 $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1484 }
1485 $content = $file['content'];
1486 if ( $isScript ) {
1487 if ( $sourceType === 'packageFile' ) {
1488 // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1489 // $/jQuery are simply used as globals instead.
1490 // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1491 $minifier->addOutput( "function(require,module,exports){" );
1492 $minifier->addSourceFile( $url, $content, true );
1493 $minifier->ensureNewline();
1494 $minifier->addOutput( "}" );
1495 } else {
1496 $minifier->addSourceFile( $url, $content, true );
1497 $minifier->ensureNewline();
1498 }
1499 } else {
1500 $content = Html::encodeJsVar( $content, true );
1501 $minifier->addSourceFile( $url, $content, true );
1502 }
1503 }
1504
1512 private static function concatenatePlainScripts( $plainScripts ) {
1513 $s = '';
1514 foreach ( $plainScripts as $script ) {
1515 // Make the script safe to concatenate by making sure there is at least one
1516 // trailing new line at the end of the content (T29054, T162719)
1517 $s .= self::ensureNewline( $script['content'] );
1518 }
1519 return $s;
1520 }
1521
1530 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1531 foreach ( $plainScripts as $index => $file ) {
1532 $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1533 }
1534 }
1535
1542 private function isEmptyFileInfos( $infos ) {
1543 $len = 0;
1544 foreach ( $infos as $info ) {
1545 $len += strlen( $info['content'] ?? '' );
1546 }
1547 return $len === 0;
1548 }
1549
1557 public static function makeCombinedStyles( array $stylePairs ) {
1558 $out = [];
1559 foreach ( $stylePairs as $media => $styles ) {
1560 // FileModule::getStyle can return the styles as a string or an
1561 // array of strings. This is to allow separation in the front-end.
1562 $styles = (array)$styles;
1563 foreach ( $styles as $style ) {
1564 $style = trim( $style );
1565 // Don't output an empty "@media print { }" block (T42498)
1566 if ( $style === '' ) {
1567 continue;
1568 }
1569 // Transform the media type based on request params and config
1570 // The way that this relies on $wgRequest to propagate request params is slightly evil
1571 $media = OutputPage::transformCssMedia( $media );
1572
1573 if ( $media === '' || $media == 'all' ) {
1574 $out[] = $style;
1575 } elseif ( is_string( $media ) ) {
1576 $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1577 }
1578 // else: skip
1579 }
1580 }
1581 return $out;
1582 }
1583
1591 private static function encodeJsonForScript( $data ) {
1592 // Keep output as small as possible by disabling needless escape modes
1593 // that PHP uses by default.
1594 // However, while most module scripts are only served on HTTP responses
1595 // for JavaScript, some modules can also be embedded in the HTML as inline
1596 // scripts. This, and the fact that we sometimes need to export strings
1597 // containing user-generated content and labels that may genuinely contain
1598 // a sequences like "</script>", we need to encode either '/' or '<'.
1599 // By default PHP escapes '/'. Let's escape '<' instead which is less common
1600 // and allows URLs to mostly remain readable.
1601 $jsonFlags = JSON_UNESCAPED_SLASHES |
1602 JSON_UNESCAPED_UNICODE |
1603 JSON_HEX_TAG |
1604 JSON_HEX_AMP;
1605 if ( self::inDebugMode() ) {
1606 $jsonFlags |= JSON_PRETTY_PRINT;
1607 }
1608 return json_encode( $data, $jsonFlags );
1609 }
1610
1619 public static function makeLoaderStateScript(
1620 Context $context, array $states
1621 ) {
1622 return 'mw.loader.state('
1623 // Silently ignore invalid UTF-8 injected via 'modules' query
1624 // Don't issue server-side warnings for client errors. (T331641)
1625 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1626 . @$context->encodeJson( $states )
1627 . ');';
1628 }
1629
1630 private static function isEmptyObject( stdClass $obj ) {
1631 foreach ( $obj as $value ) {
1632 return false;
1633 }
1634 return true;
1635 }
1636
1650 private static function trimArray( array &$array ): void {
1651 $i = count( $array );
1652 while ( $i-- ) {
1653 if ( $array[$i] === null
1654 || $array[$i] === []
1655 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1656 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1657 ) {
1658 unset( $array[$i] );
1659 } else {
1660 break;
1661 }
1662 }
1663 }
1664
1690 public static function makeLoaderRegisterScript(
1691 Context $context, array $modules
1692 ) {
1693 // Optimisation: Transform dependency names into indexes when possible
1694 // to produce smaller output. They are expanded by mw.loader.register on
1695 // the other end.
1696 $index = [];
1697 foreach ( $modules as $i => $module ) {
1698 // Build module name index
1699 $index[$module[0]] = $i;
1700 }
1701 foreach ( $modules as &$module ) {
1702 if ( isset( $module[2] ) ) {
1703 foreach ( $module[2] as &$dependency ) {
1704 if ( isset( $index[$dependency] ) ) {
1705 // Replace module name in dependency list with index
1706 $dependency = $index[$dependency];
1707 }
1708 }
1709 }
1710 self::trimArray( $module );
1711 }
1712
1713 return 'mw.loader.register('
1714 . $context->encodeJson( $modules )
1715 . ');';
1716 }
1717
1731 public static function makeLoaderSourcesScript(
1732 Context $context, array $sources
1733 ) {
1734 return 'mw.loader.addSource('
1735 . $context->encodeJson( $sources )
1736 . ');';
1737 }
1738
1745 public static function makeLoaderConditionalScript( $script ) {
1746 // Adds a function to lazy-created RLQ
1747 return '(RLQ=window.RLQ||[]).push(function(){' .
1748 trim( $script ) . '});';
1749 }
1750
1759 public static function makeInlineCodeWithModule( $modules, $script ) {
1760 // Adds an array to lazy-created RLQ
1761 return '(RLQ=window.RLQ||[]).push(['
1762 . self::encodeJsonForScript( $modules ) . ','
1763 . 'function(){' . trim( $script ) . '}'
1764 . ']);';
1765 }
1766
1777 public static function makeInlineScript( $script, $nonce = null ) {
1778 $js = self::makeLoaderConditionalScript( $script );
1779 return new WrappedString(
1780 Html::inlineScript( $js ),
1781 "<script>(RLQ=window.RLQ||[]).push(function(){",
1782 '});</script>'
1783 );
1784 }
1785
1794 public static function makeConfigSetScript( array $configuration ) {
1795 $json = self::encodeJsonForScript( $configuration );
1796 if ( $json === false ) {
1797 $e = new Exception(
1798 'JSON serialization of config data failed. ' .
1799 'This usually means the config data is not valid UTF-8.'
1800 );
1802 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1803 }
1804 return "mw.config.set($json);";
1805 }
1806
1820 public static function makePackedModulesString( array $modules ) {
1821 $moduleMap = []; // [ prefix => [ suffixes ] ]
1822 foreach ( $modules as $module ) {
1823 $pos = strrpos( $module, '.' );
1824 $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1825 $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1826 $moduleMap[$prefix][] = $suffix;
1827 }
1828
1829 $arr = [];
1830 foreach ( $moduleMap as $prefix => $suffixes ) {
1831 $p = $prefix === '' ? '' : $prefix . '.';
1832 $arr[] = $p . implode( ',', $suffixes );
1833 }
1834 return implode( '|', $arr );
1835 }
1836
1848 public static function expandModuleNames( $modules ) {
1849 $retval = [];
1850 $exploded = explode( '|', $modules );
1851 foreach ( $exploded as $group ) {
1852 if ( strpos( $group, ',' ) === false ) {
1853 // This is not a set of modules in foo.bar,baz notation
1854 // but a single module
1855 $retval[] = $group;
1856 continue;
1857 }
1858 // This is a set of modules in foo.bar,baz notation
1859 $pos = strrpos( $group, '.' );
1860 if ( $pos === false ) {
1861 // Prefixless modules, i.e. without dots
1862 $retval = array_merge( $retval, explode( ',', $group ) );
1863 continue;
1864 }
1865 // We have a prefix and a bunch of suffixes
1866 $prefix = substr( $group, 0, $pos ); // 'foo'
1867 $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1868 foreach ( $suffixes as $suffix ) {
1869 $retval[] = "$prefix.$suffix";
1870 }
1871 }
1872 return $retval;
1873 }
1874
1885 public static function inDebugMode() {
1886 if ( self::$debugMode === null ) {
1887 global $wgRequest;
1888
1889 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1890 MainConfigNames::ResourceLoaderDebug );
1891 $str = $wgRequest->getRawVal( 'debug',
1892 $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' )
1893 );
1894 self::$debugMode = Context::debugFromString( $str );
1895 }
1896 return self::$debugMode;
1897 }
1898
1909 public static function clearCache() {
1910 self::$debugMode = null;
1911 }
1912
1922 public function createLoaderURL( $source, Context $context,
1923 array $extraQuery = []
1924 ) {
1925 $query = self::createLoaderQuery( $context, $extraQuery );
1926 $script = $this->getLoadScript( $source );
1927
1928 return wfAppendQuery( $script, $query );
1929 }
1930
1940 protected static function createLoaderQuery(
1941 Context $context, array $extraQuery = []
1942 ) {
1943 return self::makeLoaderQuery(
1944 $context->getModules(),
1945 $context->getLanguage(),
1946 $context->getSkin(),
1947 $context->getUser(),
1948 $context->getVersion(),
1949 $context->getDebug(),
1950 $context->getOnly(),
1951 $context->getRequest()->getBool( 'printable' ),
1952 null,
1953 $extraQuery
1954 );
1955 }
1956
1973 public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1974 $version = null, $debug = Context::DEBUG_OFF, $only = null,
1975 $printable = false, $handheld = null, array $extraQuery = []
1976 ) {
1977 $query = [
1978 'modules' => self::makePackedModulesString( $modules ),
1979 ];
1980 // Keep urls short by omitting query parameters that
1981 // match the defaults assumed by Context.
1982 // Note: This relies on the defaults either being insignificant or forever constant,
1983 // as otherwise cached urls could change in meaning when the defaults change.
1984 if ( $lang !== Context::DEFAULT_LANG ) {
1985 $query['lang'] = $lang;
1986 }
1987 if ( $skin !== Context::DEFAULT_SKIN ) {
1988 $query['skin'] = $skin;
1989 }
1990 if ( $debug !== Context::DEBUG_OFF ) {
1991 $query['debug'] = strval( $debug );
1992 }
1993 if ( $user !== null ) {
1994 $query['user'] = $user;
1995 }
1996 if ( $version !== null ) {
1997 $query['version'] = $version;
1998 }
1999 if ( $only !== null ) {
2000 $query['only'] = $only;
2001 }
2002 if ( $printable ) {
2003 $query['printable'] = 1;
2004 }
2005 foreach ( $extraQuery as $name => $value ) {
2006 $query[$name] = $value;
2007 }
2008
2009 // Make queries uniform in order
2010 ksort( $query );
2011 return $query;
2012 }
2013
2023 public static function isValidModuleName( $moduleName ) {
2024 $len = strlen( $moduleName );
2025 return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
2026 }
2027
2038 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2039 global $IP;
2040 // When called from the installer, it is possible that a required PHP extension
2041 // is missing (at least for now; see T49564). If this is the case, throw an
2042 // exception (caught by the installer) to prevent a fatal error later on.
2043 if ( !class_exists( Less_Parser::class ) ) {
2044 throw new RuntimeException( 'MediaWiki requires the less.php parser' );
2045 }
2046
2047 $importDirs[] = "$IP/resources/src/mediawiki.less";
2048
2049 $parser = new Less_Parser;
2050 $parser->ModifyVars( $vars );
2051 $parser->SetOption( 'relativeUrls', false );
2052
2053 // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2054 $formattedImportDirs = array_fill_keys( $importDirs, '' );
2055 // Add a callback to the import dirs array for path remapping
2056 $formattedImportDirs[] = static function ( $path ) {
2057 global $IP;
2058 $importMap = [
2059 '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
2060 'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
2061 '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
2062 throw new RuntimeException(
2063 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2064 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2065 );
2066 }
2067 ];
2068 foreach ( $importMap as $importPath => $substPath ) {
2069 if ( str_starts_with( $path, $importPath ) ) {
2070 $restOfPath = substr( $path, strlen( $importPath ) );
2071 if ( is_callable( $substPath ) ) {
2072 $resolvedPath = call_user_func( $substPath, $restOfPath );
2073 } else {
2074 $filePath = $substPath . $restOfPath;
2075
2076 $resolvedPath = null;
2077 if ( file_exists( $filePath ) ) {
2078 $resolvedPath = $filePath;
2079 } elseif ( file_exists( "$filePath.less" ) ) {
2080 $resolvedPath = "$filePath.less";
2081 }
2082 }
2083
2084 if ( $resolvedPath !== null ) {
2085 return [
2086 Less_Environment::normalizePath( $resolvedPath ),
2087 Less_Environment::normalizePath( dirname( $path ) )
2088 ];
2089 } else {
2090 break;
2091 }
2092 }
2093 }
2094 return [ null, null ];
2095 };
2096 $parser->SetImportDirs( $formattedImportDirs );
2097
2098 return $parser;
2099 }
2100
2114 public function expandUrl( string $base, string $url ): string {
2115 // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2116 $isProtoRelative = strpos( $base, '//' ) === 0;
2117 if ( $isProtoRelative ) {
2118 $base = "https:$base";
2119 }
2120 // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2121 $baseUrl = new Net_URL2( $base );
2122 $ret = $baseUrl->resolve( $url );
2123 if ( $isProtoRelative ) {
2124 $ret->setScheme( false );
2125 }
2126 return $ret->getURL();
2127 }
2128
2146 public static function filter( $filter, $data, array $options = [] ) {
2147 if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2148 return $data;
2149 }
2150
2151 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2152 return self::applyFilter( $filter, $data ) ?? $data;
2153 }
2154
2155 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
2157
2158 $key = $cache->makeGlobalKey(
2159 'resourceloader-filter',
2160 $filter,
2161 self::CACHE_VERSION,
2162 md5( $data )
2163 );
2164
2165 $incKey = "resourceloader_cache.$filter.hit";
2166 $result = $cache->getWithSetCallback(
2167 $key,
2168 BagOStuff::TTL_DAY,
2169 static function () use ( $filter, $data, &$incKey ) {
2170 $incKey = "resourceloader_cache.$filter.miss";
2171 return self::applyFilter( $filter, $data );
2172 }
2173 );
2174 $stats->increment( $incKey );
2175
2176 // Use $data on cache failure
2177 return $result ?? $data;
2178 }
2179
2185 private static function applyFilter( $filter, $data ) {
2186 $data = trim( $data );
2187 if ( $data ) {
2188 try {
2189 $data = ( $filter === 'minify-css' )
2190 ? CSSMin::minify( $data )
2191 : JavaScriptMinifier::minify( $data );
2192 } catch ( TimeoutException $e ) {
2193 throw $e;
2194 } catch ( Exception $e ) {
2196 return null;
2197 }
2198 }
2199 return $data;
2200 }
2201
2213 public static function getUserDefaults(
2214 Context $context,
2215 HookContainer $hookContainer,
2216 UserOptionsLookup $userOptionsLookup
2217 ): array {
2218 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2219 $keysToExclude = [];
2220 $hookRunner = new HookRunner( $hookContainer );
2221 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2222 foreach ( $keysToExclude as $excludedKey ) {
2223 unset( $defaultOptions[ $excludedKey ] );
2224 }
2225 return $defaultOptions;
2226 }
2227
2236 public static function getSiteConfigSettings(
2237 Context $context, Config $conf
2238 ): array {
2239 $services = MediaWikiServices::getInstance();
2240 // Namespace related preparation
2241 // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2242 // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2243 $contLang = $services->getContentLanguage();
2244 $namespaceIds = $contLang->getNamespaceIds();
2245 $caseSensitiveNamespaces = [];
2246 $nsInfo = $services->getNamespaceInfo();
2247 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2248 $namespaceIds[$contLang->lc( $name )] = $index;
2249 if ( !$nsInfo->isCapitalized( $index ) ) {
2250 $caseSensitiveNamespaces[] = $index;
2251 }
2252 }
2253
2254 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2255
2256 // Build list of variables
2257 $skin = $context->getSkin();
2258
2259 // Start of supported and stable config vars (for use by extensions/gadgets).
2260 $vars = [
2261 'debug' => $context->getDebug(),
2262 'skin' => $skin,
2263 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2264 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2265 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2266 'wgScript' => $conf->get( MainConfigNames::Script ),
2267 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2268 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2269 'wgServer' => $conf->get( MainConfigNames::Server ),
2270 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2271 'wgUserLanguage' => $context->getLanguage(),
2272 'wgContentLanguage' => $contLang->getCode(),
2273 'wgVersion' => MW_VERSION,
2274 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2275 'wgNamespaceIds' => $namespaceIds,
2276 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2277 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2278 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2279 'wgWikiID' => WikiMap::getCurrentWikiId(),
2280 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2281 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2282 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2283 ];
2284 // End of stable config vars.
2285
2286 // Internal variables for use by MediaWiki core and/or ResourceLoader.
2287 $vars += [
2288 // @internal For mediawiki.widgets
2289 'wgUrlProtocols' => wfUrlProtocols(),
2290 // @internal For mediawiki.page.watch
2291 // Force object to avoid "empty" associative array from
2292 // becoming [] instead of {} in JS (T36604)
2293 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2294 // @internal For mediawiki.language
2295 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2296 // @internal For mediawiki.Title
2297 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2298 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2299 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2300 ];
2301
2302 ( new HookRunner( $services->getHookContainer() ) )
2303 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2304
2305 return $vars;
2306 }
2307
2312 public function getErrors() {
2313 return $this->errors;
2314 }
2315}
2316
2320class_alias( ResourceLoader::class, 'ResourceLoader' );
const CACHE_ANYTHING
Definition Defines.php:85
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:36
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.
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:97
global $wgRequest
Definition Setup.php:415
const MW_ENTRY_POINT
Definition api.php:44
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:57
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:48
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:674
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:1064
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, IReadableDatabase $db, array $moduleNames)
Represents a title within MediaWiki.
Definition Title.php:79
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.
MediaWiki adaptation of StatsdDataFactory that provides buffering functionality.
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...
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(!is_readable( $file)) $ext
Definition router.php:48
$header