MediaWiki master
ResourceLoader.php
Go to the documentation of this file.
1<?php
24
25use Exception;
26use HttpStatus;
27use InvalidArgumentException;
28use Less_Environment;
29use Less_Parser;
30use LogicException;
49use Net_URL2;
50use Psr\Log\LoggerAwareInterface;
51use Psr\Log\LoggerInterface;
52use Psr\Log\NullLogger;
53use RuntimeException;
54use stdClass;
55use Throwable;
56use UnexpectedValueException;
59use Wikimedia\Minify\CSSMin;
60use Wikimedia\Minify\IdentityMinifierState;
61use Wikimedia\Minify\IndexMap;
62use Wikimedia\Minify\IndexMapOffset;
63use Wikimedia\Minify\JavaScriptMapperState;
64use Wikimedia\Minify\JavaScriptMinifier;
65use Wikimedia\Minify\JavaScriptMinifierState;
66use Wikimedia\Minify\MinifierState;
69use Wikimedia\RequestTimeout\TimeoutException;
70use 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 $statsFactory;
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->statsFactory = $services->getStatsFactory();
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 ' . get_debug_type( $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 WikiModule::preloadTitleInfo( $context, $moduleNames );
462
463 // Prime in-object cache for message blobs for modules with messages
464 $modulesWithMessages = [];
465 foreach ( $moduleNames as $moduleName ) {
466 $module = $this->getModule( $moduleName );
467 if ( $module && $module->getMessages() ) {
468 $modulesWithMessages[$moduleName] = $module;
469 }
470 }
471 // Prime in-object cache for message blobs for modules with messages
472 $lang = $context->getLanguage();
473 $store = $this->getMessageBlobStore();
474 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
475 foreach ( $blobs as $moduleName => $blob ) {
476 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
477 }
478 }
479
486 public function loadModuleDependenciesInternal( $moduleName, $variant ) {
487 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
488
489 return Module::expandRelativePaths( $deps['paths'] );
490 }
491
499 public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
500 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
501 $entity = "$moduleName|$variant";
502
503 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
504 // Dependency store needs to be updated with the new path list
505 if ( $paths ) {
506 $deps = $this->depStore->newEntityDependencies( $paths, time() );
507 $this->depStoreUpdateBuffer[$entity] = $deps;
508 } else {
509 $this->depStoreUpdateBuffer[$entity] = null;
510 }
511 }
512
513 // If paths were unchanged, leave the dependency store unchanged also.
514 // The entry will eventually expire, after which we will briefly issue an incomplete
515 // version hash for a 5-min startup window, the module then recomputes and rediscovers
516 // the paths and arrive at the same module version hash once again. It will churn
517 // part of the browser cache once, for clients connecting during that window.
518
519 if ( !$hasPendingUpdate ) {
520 DeferredUpdates::addCallableUpdate( function () {
521 $updatesByEntity = $this->depStoreUpdateBuffer;
522 $this->depStoreUpdateBuffer = [];
524 ->getObjectCacheFactory()->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 echo $response;
861 }
862
867 protected function measureResponseTime() {
868 $statStart = $_SERVER['REQUEST_TIME_FLOAT'];
869 return new ScopedCallback( function () use ( $statStart ) {
870 $statTiming = microtime( true ) - $statStart;
871
872 $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' )
873 ->copyToStatsdAt( 'resourceloader.responseTime' )
874 ->observe( 1000 * $statTiming );
875 } );
876 }
877
888 protected function sendResponseHeaders(
889 Context $context, $etag, $errors, array $extra = []
890 ): void {
891 HeaderCallback::warnIfHeadersSent();
892
893 if ( $errors ) {
894 $maxage = self::MAXAGE_RECOVER;
895 } elseif (
896 $context->getVersion() !== null
897 && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
898 ) {
899 // If we need to self-correct, set a very short cache expiry
900 // to basically just debounce CDN traffic. This applies to:
901 // - Internal errors, e.g. due to misconfiguration.
902 // - Version mismatch, e.g. due to deployment race (T117587, T47877).
903 $this->logger->debug( 'Client and server registry version out of sync' );
904 $maxage = self::MAXAGE_RECOVER;
905 } elseif ( $context->getVersion() === null ) {
906 // Resources that can't set a version, should have their updates propagate to
907 // clients quickly. This applies to shared resources linked from HTML, such as
908 // the startup module and stylesheets.
909 $maxage = $this->maxageUnversioned;
910 } else {
911 // When a version is set, use a long expiry because changes
912 // will naturally miss the cache by using a different URL.
913 $maxage = $this->maxageVersioned;
914 }
915 if ( $context->getImageObj() ) {
916 // Output different headers if we're outputting textual errors.
917 if ( $errors ) {
918 header( 'Content-Type: text/plain; charset=utf-8' );
919 } else {
920 $context->getImageObj()->sendResponseHeaders( $context );
921 }
922 } elseif ( $context->isSourceMap() ) {
923 header( 'Content-Type: application/json' );
924 } elseif ( $context->getOnly() === 'styles' ) {
925 header( 'Content-Type: text/css; charset=utf-8' );
926 header( 'Access-Control-Allow-Origin: *' );
927 } else {
928 header( 'Content-Type: text/javascript; charset=utf-8' );
929 }
930 // See RFC 2616 § 14.19 ETag
931 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
932 header( 'ETag: ' . $etag );
933 if ( $context->getDebug() ) {
934 // Do not cache debug responses
935 header( 'Cache-Control: private, no-cache, must-revalidate' );
936 } else {
937 // T132418: When a resource expires mid-way a browsing session, prefer to renew it in
938 // the background instead of blocking the next page load (eg. startup module, or CSS).
939 $staleDirective = ( $maxage > self::MAXAGE_RECOVER
940 ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) )
941 : ''
942 );
943 header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective );
944 header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
945 }
946 foreach ( $extra as $header ) {
947 header( $header );
948 }
949 }
950
961 protected function tryRespondNotModified( Context $context, $etag ) {
962 // See RFC 2616 § 14.26 If-None-Match
963 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
964 $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
965 // Never send 304s in debug mode
966 if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
967 // There's another bug in ob_gzhandler (see also the comment at
968 // the top of this function) that causes it to gzip even empty
969 // responses, meaning it's impossible to produce a truly empty
970 // response (because the gzip header is always there). This is
971 // a problem because 304 responses have to be completely empty
972 // per the HTTP spec, and Firefox behaves buggily when they're not.
973 // See also https://bugs.php.net/bug.php?id=51579
974 // To work around this, we tear down all output buffering before
975 // sending the 304.
976 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
977
978 HttpStatus::header( 304 );
979
980 $this->sendResponseHeaders( $context, $etag, false );
981 return true;
982 }
983 return false;
984 }
985
993 private function getSourceMapUrl( Context $context, $version ) {
994 return $this->createLoaderURL( 'local', $context, [
995 'sourcemap' => '1',
996 'version' => $version
997 ] );
998 }
999
1005 private function sendSourceMapVersionMismatch( $currentVersion ) {
1006 HttpStatus::header( 404 );
1007 header( 'Content-Type: text/plain; charset=utf-8' );
1008 header( 'X-Content-Type-Options: nosniff' );
1009 echo "Can't deliver a source map for the requested version " .
1010 "since the version is now '$currentVersion'\n";
1011 }
1012
1017 private function sendSourceMapTypeNotImplemented() {
1018 HttpStatus::header( 404 );
1019 header( 'Content-Type: text/plain; charset=utf-8' );
1020 header( 'X-Content-Type-Options: nosniff' );
1021 echo "Can't make a source map for this content type\n";
1022 }
1023
1032 public static function makeComment( $text ) {
1033 $encText = str_replace( '*/', '* /', $text );
1034 return "/*\n$encText\n*/\n";
1035 }
1036
1044 protected static function formatExceptionNoComment( Throwable $e ) {
1045 if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) {
1046 return MWExceptionHandler::getPublicLogMessage( $e );
1047 }
1048
1049 // Like MWExceptionHandler::getLogMessage but without $url and $id.
1050 // - Long load.php URL would push the actual error message off-screen into
1051 // scroll overflow in browser devtools.
1052 // - reqId is redundant with X-Request-Id header, plus usually no need to
1053 // correlate the reqId since the backtrace is already included below.
1054 $type = get_class( $e );
1055 $message = $e->getMessage();
1056
1057 return "$type: $message" .
1058 "\nBacktrace:\n" .
1059 MWExceptionHandler::getRedactedTraceAsString( $e );
1060 }
1061
1073 public function makeModuleResponse( Context $context,
1074 array $modules, array $missing = []
1075 ) {
1076 if ( $modules === [] && $missing === [] ) {
1077 return <<<MESSAGE
1078/* This file is the Web entry point for MediaWiki's ResourceLoader:
1079 <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
1080 no modules were requested. Max made me put this here. */
1081MESSAGE;
1082 }
1083
1084 $image = $context->getImageObj();
1085 if ( $image ) {
1086 $data = $image->getImageData( $context );
1087 if ( $data === false ) {
1088 $data = '';
1089 $this->errors[] = 'Image generation failed';
1090 }
1091 return $data;
1092 }
1093
1094 $states = [];
1095 foreach ( $missing as $name ) {
1096 $states[$name] = 'missing';
1097 }
1098
1099 $only = $context->getOnly();
1100 $debug = (bool)$context->getDebug();
1101 if ( $context->isSourceMap() && count( $modules ) > 1 ) {
1102 $indexMap = new IndexMap;
1103 } else {
1104 $indexMap = null;
1105 }
1106
1107 $out = '';
1108 foreach ( $modules as $name => $module ) {
1109 try {
1110 [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module );
1111 if ( $indexMap ) {
1112 $indexMap->addEncodedMap( $response, $offset );
1113 } else {
1114 $out .= $response;
1115 }
1116 } catch ( TimeoutException $e ) {
1117 throw $e;
1118 } catch ( Exception $e ) {
1119 $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
1120
1121 // Respond to client with error-state instead of module implementation
1122 $states[$name] = 'error';
1123 unset( $modules[$name] );
1124 }
1125 }
1126
1127 // Update module states
1128 if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
1129 if ( $modules && $only === 'scripts' ) {
1130 // Set the state of modules loaded as only scripts to ready as
1131 // they don't have an mw.loader.impl wrapper that sets the state
1132 foreach ( $modules as $name => $module ) {
1133 $states[$name] = 'ready';
1134 }
1135 }
1136
1137 // Set the state of modules we didn't respond to with mw.loader.impl
1138 if ( $states && !$context->isSourceMap() ) {
1139 $stateScript = self::makeLoaderStateScript( $context, $states );
1140 if ( !$debug ) {
1141 $stateScript = self::filter( 'minify-js', $stateScript );
1142 }
1143 // Use a linebreak between module script and state script (T162719)
1144 $out = self::ensureNewline( $out ) . $stateScript;
1145 }
1146 } elseif ( $states ) {
1147 $this->errors[] = 'Problematic modules: '
1148 // Silently ignore invalid UTF-8 injected via 'modules' query
1149 // Don't issue server-side warnings for client errors. (T331641)
1150 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1151 . @$context->encodeJson( $states );
1152 }
1153
1154 if ( $indexMap ) {
1155 return $indexMap->getMap();
1156 } else {
1157 return $out;
1158 }
1159 }
1160
1169 private function getOneModuleResponse( Context $context, $name, Module $module ) {
1170 $only = $context->getOnly();
1171 // Important: Do not cache minifications of embedded modules
1172 // This is especially for the private 'user.options' module,
1173 // which varies on every pageview and would explode the cache (T84960)
1174 $shouldCache = !$module->shouldEmbedModule( $context );
1175 if ( $only === 'styles' ) {
1176 $minifier = new IdentityMinifierState;
1177 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1178 // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper
1179 // to ease code reuse. The filter() call below performs CSS minification.
1180 $styles = $minifier->getMinifiedOutput();
1181 if ( $context->getDebug() ) {
1182 return [ $styles, null ];
1183 }
1184 return [
1185 self::filter( 'minify-css', $styles,
1186 [ 'cache' => $shouldCache ] ),
1187 null
1188 ];
1189 }
1190
1191 $minifier = new IdentityMinifierState;
1192 $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders );
1193 $plainContent = $minifier->getMinifiedOutput();
1194 if ( $context->getDebug() ) {
1195 return [ $plainContent, null ];
1196 }
1197
1198 $isHit = true;
1199 $callback = function () use ( $context, $name, $module, &$isHit ) {
1200 $isHit = false;
1201 if ( $context->isSourceMap() ) {
1202 $minifier = ( new JavaScriptMapperState )
1203 ->outputFile( $this->createLoaderURL( 'local', $context, [
1204 'modules' => self::makePackedModulesString( $context->getModules() ),
1205 'only' => $context->getOnly()
1206 ] ) );
1207 } else {
1208 $minifier = new JavaScriptMinifierState;
1209 }
1210 // We only need to add one set of headers, and we did that for the identity response
1211 $discardedHeaders = null;
1212 $this->addOneModuleResponse( $context, $minifier, $name, $module, $discardedHeaders );
1213 if ( $context->isSourceMap() ) {
1214 $sourceMap = $minifier->getRawSourceMap();
1215 $generated = $minifier->getMinifiedOutput();
1216 $offset = IndexMapOffset::newFromText( $generated );
1217 return [ $sourceMap, $offset->toArray() ];
1218 } else {
1219 return [ $minifier->getMinifiedOutput(), null ];
1220 }
1221 };
1222
1223 if ( $shouldCache ) {
1224 [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback(
1225 $this->srvCache->makeGlobalKey(
1226 'resourceloader-mapped',
1227 self::CACHE_VERSION,
1228 $name,
1229 $context->isSourceMap() ? '1' : '0',
1230 md5( $plainContent )
1231 ),
1232 BagOStuff::TTL_DAY,
1233 $callback
1234 );
1235
1236 $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js';
1237 $statsdNamespace = implode( '.', [
1238 "resourceloader_cache", $mapType, $isHit ? 'hit' : 'miss'
1239 ] );
1240 $this->statsFactory->getCounter( 'resourceloader_cache_total' )
1241 ->setLabel( 'type', $mapType )
1242 ->setLabel( 'status', $isHit ? 'hit' : 'miss' )
1243 ->copyToStatsdAt( [ $statsdNamespace ] )
1244 ->increment();
1245 } else {
1246 [ $response, $offsetArray ] = $callback();
1247 }
1248 $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null;
1249
1250 return [ $response, $offset ];
1251 }
1252
1263 private function addOneModuleResponse(
1264 Context $context, MinifierState $minifier, $name, Module $module, &$headers
1265 ) {
1266 $only = $context->getOnly();
1267 $debug = (bool)$context->getDebug();
1268 $content = $module->getModuleContent( $context );
1269 $version = $module->getVersionHash( $context );
1270
1271 if ( $headers !== null && isset( $content['headers'] ) ) {
1272 $headers = array_merge( $headers, $content['headers'] );
1273 }
1274
1275 // Append output
1276 switch ( $only ) {
1277 case 'scripts':
1278 $scripts = $content['scripts'];
1279 if ( !is_array( $scripts ) ) {
1280 // Formerly scripts was usually a string, but now it is
1281 // normalized to an array by buildContent().
1282 throw new InvalidArgumentException( 'scripts must be an array' );
1283 }
1284 if ( isset( $scripts['plainScripts'] ) ) {
1285 // Add plain scripts
1286 $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] );
1287 } elseif ( isset( $scripts['files'] ) ) {
1288 // Add implement call if any
1289 $this->addImplementScript(
1290 $minifier,
1291 $name,
1292 $version,
1293 $scripts,
1294 [],
1295 null,
1296 [],
1297 $content['deprecationWarning'] ?? null
1298 );
1299 }
1300 break;
1301 case 'styles':
1302 $styles = $content['styles'];
1303 // We no longer separate into media, they are all combined now with
1304 // custom media type groups into @media .. {} sections as part of the css string.
1305 // Module returns either an empty array or a numerical array with css strings.
1306 if ( isset( $styles['css'] ) ) {
1307 $minifier->addOutput( implode( '', $styles['css'] ) );
1308 }
1309 break;
1310 default:
1311 $scripts = $content['scripts'] ?? '';
1312 if ( ( $name === 'site' || $name === 'user' )
1313 && isset( $scripts['plainScripts'] )
1314 ) {
1315 // Legacy scripts that run in the global scope without a closure.
1316 // mw.loader.impl will use eval if scripts is a string.
1317 // Minify manually here, because general response minification is
1318 // not effective due it being a string literal, not a function.
1319 $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] );
1320 if ( !$debug ) {
1321 $scripts = self::filter( 'minify-js', $scripts ); // T107377
1322 }
1323 }
1324 $this->addImplementScript(
1325 $minifier,
1326 $name,
1327 $version,
1328 $scripts,
1329 $content['styles'] ?? [],
1330 isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null,
1331 $content['templates'] ?? [],
1332 $content['deprecationWarning'] ?? null
1333 );
1334 break;
1335 }
1336 $minifier->ensureNewline();
1337 }
1338
1345 public static function ensureNewline( $str ) {
1346 $end = substr( $str, -1 );
1347 if ( $end === false || $end === '' || $end === "\n" ) {
1348 return $str;
1349 }
1350 return $str . "\n";
1351 }
1352
1359 public function getModulesByMessage( $messageKey ) {
1360 $moduleNames = [];
1361 foreach ( $this->getModuleNames() as $moduleName ) {
1362 $module = $this->getModule( $moduleName );
1363 if ( in_array( $messageKey, $module->getMessages() ) ) {
1364 $moduleNames[] = $moduleName;
1365 }
1366 }
1367 return $moduleNames;
1368 }
1369
1391 private function addImplementScript( MinifierState $minifier,
1392 $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning
1393 ) {
1394 $implementKey = "$moduleName@$version";
1395 // Plain functions are used instead of arrow functions to avoid
1396 // defeating lazy compilation on Chrome. (T343407)
1397 $minifier->addOutput( "mw.loader.impl(function(){return[" .
1398 Html::encodeJsVar( $implementKey ) . "," );
1399
1400 // Scripts
1401 if ( is_string( $scripts ) ) {
1402 // user/site script
1403 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1404 } elseif ( is_array( $scripts ) ) {
1405 if ( isset( $scripts['files'] ) ) {
1406 $minifier->addOutput(
1407 "{\"main\":" .
1408 Html::encodeJsVar( $scripts['main'] ) .
1409 ",\"files\":" );
1410 $this->addFiles( $minifier, $moduleName, $scripts['files'] );
1411 $minifier->addOutput( "}" );
1412 } elseif ( isset( $scripts['plainScripts'] ) ) {
1413 if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) {
1414 $minifier->addOutput( 'null' );
1415 } else {
1416 $minifier->addOutput( "function($,jQuery,require,module){" );
1417 $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] );
1418 $minifier->addOutput( "}" );
1419 }
1420 } elseif ( $scripts === [] || isset( $scripts[0] ) ) {
1421 // Array of URLs
1422 $minifier->addOutput( Html::encodeJsVar( $scripts ) );
1423 } else {
1424 throw new InvalidArgumentException( 'Invalid script array: ' .
1425 'must contain files, plainScripts or be an array of URLs' );
1426 }
1427 } else {
1428 throw new InvalidArgumentException( 'Script must be a string or array' );
1429 }
1430
1431 // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not
1432 // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
1433 // of "{}". Force them to objects.
1434 $extraArgs = [
1435 (object)$styles,
1436 $messages ?? (object)[],
1437 (object)$templates,
1438 $deprecationWarning
1439 ];
1440 self::trimArray( $extraArgs );
1441 foreach ( $extraArgs as $arg ) {
1442 $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) );
1443 }
1444 $minifier->addOutput( "];});" );
1445 }
1446
1457 private function addFiles( MinifierState $minifier, $moduleName, $files ) {
1458 $first = true;
1459 $minifier->addOutput( "{" );
1460 foreach ( $files as $fileName => $file ) {
1461 if ( $first ) {
1462 $first = false;
1463 } else {
1464 $minifier->addOutput( "," );
1465 }
1466 $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' );
1467 $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file );
1468 }
1469 $minifier->addOutput( "}" );
1470 }
1471
1481 private function addFileContent( MinifierState $minifier,
1482 $moduleName, $sourceType, $sourceIndex, array $file
1483 ) {
1484 $isScript = ( $file['type'] ?? 'script' ) === 'script';
1486 $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null;
1487 if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) {
1488 $url = $filePath->getRemotePath();
1489 } else {
1490 $ext = $isScript ? 'js' : 'json';
1491 $scriptPath = $this->config->has( MainConfigNames::ScriptPath )
1492 ? $this->config->get( MainConfigNames::ScriptPath ) : '';
1493 $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext";
1494 }
1495 $content = $file['content'];
1496 if ( $isScript ) {
1497 if ( $sourceType === 'packageFile' ) {
1498 // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511).
1499 // $/jQuery are simply used as globals instead.
1500 // TODO: Remove $/jQuery param from traditional module closure too (and bump caching)
1501 $minifier->addOutput( "function(require,module,exports){" );
1502 $minifier->addSourceFile( $url, $content, true );
1503 $minifier->ensureNewline();
1504 $minifier->addOutput( "}" );
1505 } else {
1506 $minifier->addSourceFile( $url, $content, true );
1507 $minifier->ensureNewline();
1508 }
1509 } else {
1510 $content = Html::encodeJsVar( $content, true );
1511 $minifier->addSourceFile( $url, $content, true );
1512 }
1513 }
1514
1522 private static function concatenatePlainScripts( $plainScripts ) {
1523 $s = '';
1524 foreach ( $plainScripts as $script ) {
1525 // Make the script safe to concatenate by making sure there is at least one
1526 // trailing new line at the end of the content (T29054, T162719)
1527 $s .= self::ensureNewline( $script['content'] );
1528 }
1529 return $s;
1530 }
1531
1540 private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) {
1541 foreach ( $plainScripts as $index => $file ) {
1542 $this->addFileContent( $minifier, $moduleName, 'script', $index, $file );
1543 }
1544 }
1545
1552 private function isEmptyFileInfos( $infos ) {
1553 $len = 0;
1554 foreach ( $infos as $info ) {
1555 $len += strlen( $info['content'] ?? '' );
1556 }
1557 return $len === 0;
1558 }
1559
1567 public static function makeCombinedStyles( array $stylePairs ) {
1568 $out = [];
1569 foreach ( $stylePairs as $media => $styles ) {
1570 // FileModule::getStyle can return the styles as a string or an
1571 // array of strings. This is to allow separation in the front-end.
1572 $styles = (array)$styles;
1573 foreach ( $styles as $style ) {
1574 $style = trim( $style );
1575 // Don't output an empty "@media print { }" block (T42498)
1576 if ( $style === '' ) {
1577 continue;
1578 }
1579 // Transform the media type based on request params and config
1580 // The way that this relies on $wgRequest to propagate request params is slightly evil
1581 $media = OutputPage::transformCssMedia( $media );
1582
1583 if ( $media === '' || $media == 'all' ) {
1584 $out[] = $style;
1585 } elseif ( is_string( $media ) ) {
1586 $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
1587 }
1588 // else: skip
1589 }
1590 }
1591 return $out;
1592 }
1593
1601 private static function encodeJsonForScript( $data ) {
1602 // Keep output as small as possible by disabling needless escape modes
1603 // that PHP uses by default.
1604 // However, while most module scripts are only served on HTTP responses
1605 // for JavaScript, some modules can also be embedded in the HTML as inline
1606 // scripts. This, and the fact that we sometimes need to export strings
1607 // containing user-generated content and labels that may genuinely contain
1608 // a sequences like "</script>", we need to encode either '/' or '<'.
1609 // By default PHP escapes '/'. Let's escape '<' instead which is less common
1610 // and allows URLs to mostly remain readable.
1611 $jsonFlags = JSON_UNESCAPED_SLASHES |
1612 JSON_UNESCAPED_UNICODE |
1613 JSON_HEX_TAG |
1614 JSON_HEX_AMP;
1615 if ( self::inDebugMode() ) {
1616 $jsonFlags |= JSON_PRETTY_PRINT;
1617 }
1618 return json_encode( $data, $jsonFlags );
1619 }
1620
1629 public static function makeLoaderStateScript(
1630 Context $context, array $states
1631 ) {
1632 return 'mw.loader.state('
1633 // Silently ignore invalid UTF-8 injected via 'modules' query
1634 // Don't issue server-side warnings for client errors. (T331641)
1635 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1636 . @$context->encodeJson( $states )
1637 . ');';
1638 }
1639
1640 private static function isEmptyObject( stdClass $obj ) {
1641 foreach ( $obj as $value ) {
1642 return false;
1643 }
1644 return true;
1645 }
1646
1660 private static function trimArray( array &$array ): void {
1661 $i = count( $array );
1662 while ( $i-- ) {
1663 if ( $array[$i] === null
1664 || $array[$i] === []
1665 || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' )
1666 || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
1667 ) {
1668 unset( $array[$i] );
1669 } else {
1670 break;
1671 }
1672 }
1673 }
1674
1700 public static function makeLoaderRegisterScript(
1701 Context $context, array $modules
1702 ) {
1703 // Optimisation: Transform dependency names into indexes when possible
1704 // to produce smaller output. They are expanded by mw.loader.register on
1705 // the other end.
1706 $index = [];
1707 foreach ( $modules as $i => $module ) {
1708 // Build module name index
1709 $index[$module[0]] = $i;
1710 }
1711 foreach ( $modules as &$module ) {
1712 if ( isset( $module[2] ) ) {
1713 foreach ( $module[2] as &$dependency ) {
1714 if ( isset( $index[$dependency] ) ) {
1715 // Replace module name in dependency list with index
1716 $dependency = $index[$dependency];
1717 }
1718 }
1719 }
1720 self::trimArray( $module );
1721 }
1722
1723 return 'mw.loader.register('
1724 . $context->encodeJson( $modules )
1725 . ');';
1726 }
1727
1741 public static function makeLoaderSourcesScript(
1742 Context $context, array $sources
1743 ) {
1744 return 'mw.loader.addSource('
1745 . $context->encodeJson( $sources )
1746 . ');';
1747 }
1748
1755 public static function makeLoaderConditionalScript( $script ) {
1756 // Adds a function to lazy-created RLQ
1757 return '(RLQ=window.RLQ||[]).push(function(){' .
1758 trim( $script ) . '});';
1759 }
1760
1769 public static function makeInlineCodeWithModule( $modules, $script ) {
1770 // Adds an array to lazy-created RLQ
1771 return '(RLQ=window.RLQ||[]).push(['
1772 . self::encodeJsonForScript( $modules ) . ','
1773 . 'function(){' . trim( $script ) . '}'
1774 . ']);';
1775 }
1776
1787 public static function makeInlineScript( $script, $nonce = null ) {
1788 $js = self::makeLoaderConditionalScript( $script );
1789 return new WrappedString(
1790 Html::inlineScript( $js ),
1791 "<script>(RLQ=window.RLQ||[]).push(function(){",
1792 '});</script>'
1793 );
1794 }
1795
1804 public static function makeConfigSetScript( array $configuration ) {
1805 $json = self::encodeJsonForScript( $configuration );
1806 if ( $json === false ) {
1807 $e = new LogicException(
1808 'JSON serialization of config data failed. ' .
1809 'This usually means the config data is not valid UTF-8.'
1810 );
1812 return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
1813 }
1814 return "mw.config.set($json);";
1815 }
1816
1830 public static function makePackedModulesString( array $modules ) {
1831 $moduleMap = []; // [ prefix => [ suffixes ] ]
1832 foreach ( $modules as $module ) {
1833 $pos = strrpos( $module, '.' );
1834 $prefix = $pos === false ? '' : substr( $module, 0, $pos );
1835 $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
1836 $moduleMap[$prefix][] = $suffix;
1837 }
1838
1839 $arr = [];
1840 foreach ( $moduleMap as $prefix => $suffixes ) {
1841 $p = $prefix === '' ? '' : $prefix . '.';
1842 $arr[] = $p . implode( ',', $suffixes );
1843 }
1844 return implode( '|', $arr );
1845 }
1846
1858 public static function expandModuleNames( $modules ) {
1859 $retval = [];
1860 $exploded = explode( '|', $modules );
1861 foreach ( $exploded as $group ) {
1862 if ( strpos( $group, ',' ) === false ) {
1863 // This is not a set of modules in foo.bar,baz notation
1864 // but a single module
1865 $retval[] = $group;
1866 continue;
1867 }
1868 // This is a set of modules in foo.bar,baz notation
1869 $pos = strrpos( $group, '.' );
1870 if ( $pos === false ) {
1871 // Prefixless modules, i.e. without dots
1872 $retval = array_merge( $retval, explode( ',', $group ) );
1873 continue;
1874 }
1875 // We have a prefix and a bunch of suffixes
1876 $prefix = substr( $group, 0, $pos ); // 'foo'
1877 $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
1878 foreach ( $suffixes as $suffix ) {
1879 $retval[] = "$prefix.$suffix";
1880 }
1881 }
1882 return $retval;
1883 }
1884
1895 public static function inDebugMode() {
1896 if ( self::$debugMode === null ) {
1897 global $wgRequest;
1898
1899 $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get(
1900 MainConfigNames::ResourceLoaderDebug );
1901 $str = $wgRequest->getRawVal( 'debug' ) ??
1902 $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' );
1903 self::$debugMode = Context::debugFromString( $str );
1904 }
1905 return self::$debugMode;
1906 }
1907
1918 public static function clearCache() {
1919 self::$debugMode = null;
1920 }
1921
1931 public function createLoaderURL( $source, Context $context,
1932 array $extraQuery = []
1933 ) {
1934 $query = self::createLoaderQuery( $context, $extraQuery );
1935 $script = $this->getLoadScript( $source );
1936
1937 return wfAppendQuery( $script, $query );
1938 }
1939
1949 protected static function createLoaderQuery(
1950 Context $context, array $extraQuery = []
1951 ) {
1952 return self::makeLoaderQuery(
1953 $context->getModules(),
1954 $context->getLanguage(),
1955 $context->getSkin(),
1956 $context->getUser(),
1957 $context->getVersion(),
1958 $context->getDebug(),
1959 $context->getOnly(),
1960 $context->getRequest()->getBool( 'printable' ),
1961 null,
1962 $extraQuery
1963 );
1964 }
1965
1982 public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1983 $version = null, $debug = Context::DEBUG_OFF, $only = null,
1984 $printable = false, $handheld = null, array $extraQuery = []
1985 ) {
1986 $query = [
1987 'modules' => self::makePackedModulesString( $modules ),
1988 ];
1989 // Keep urls short by omitting query parameters that
1990 // match the defaults assumed by Context.
1991 // Note: This relies on the defaults either being insignificant or forever constant,
1992 // as otherwise cached urls could change in meaning when the defaults change.
1993 if ( $lang !== Context::DEFAULT_LANG ) {
1994 $query['lang'] = $lang;
1995 }
1996 if ( $skin !== Context::DEFAULT_SKIN ) {
1997 $query['skin'] = $skin;
1998 }
1999 if ( $debug !== Context::DEBUG_OFF ) {
2000 $query['debug'] = strval( $debug );
2001 }
2002 if ( $user !== null ) {
2003 $query['user'] = $user;
2004 }
2005 if ( $version !== null ) {
2006 $query['version'] = $version;
2007 }
2008 if ( $only !== null ) {
2009 $query['only'] = $only;
2010 }
2011 if ( $printable ) {
2012 $query['printable'] = 1;
2013 }
2014 foreach ( $extraQuery as $name => $value ) {
2015 $query[$name] = $value;
2016 }
2017
2018 // Make queries uniform in order
2019 ksort( $query );
2020 return $query;
2021 }
2022
2032 public static function isValidModuleName( $moduleName ) {
2033 $len = strlen( $moduleName );
2034 return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
2035 }
2036
2047 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2048 // When called from the installer, it is possible that a required PHP extension
2049 // is missing (at least for now; see T49564). If this is the case, throw an
2050 // exception (caught by the installer) to prevent a fatal error later on.
2051 if ( !class_exists( Less_Parser::class ) ) {
2052 throw new RuntimeException( 'MediaWiki requires the less.php parser' );
2053 }
2054
2055 $importDirs[] = MW_INSTALL_PATH . '/resources/src/mediawiki.less';
2056
2057 $parser = new Less_Parser;
2058 $parser->ModifyVars( $vars );
2059 $parser->SetOption( 'relativeUrls', false );
2060 $parser->SetOption( 'math', 'always' );
2061
2062 // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2063 $formattedImportDirs = array_fill_keys( $importDirs, '' );
2064
2065 // Add a callback to the import dirs array for path remapping
2066 $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir );
2067 $formattedImportDirs[] = static function ( $path ) use ( $codexDevDir ) {
2068 // For each of the Codex import paths, use CodexDevelopmentDir if it's set
2069 $importMap = [
2070 '@wikimedia/codex-icons/' => $codexDevDir !== null ?
2071 "$codexDevDir/packages/codex-icons/dist/" :
2072 MW_INSTALL_PATH . '/resources/lib/codex-icons/',
2073 'mediawiki.skin.codex/' => $codexDevDir !== null ?
2074 "$codexDevDir/packages/codex/dist/" :
2075 MW_INSTALL_PATH . '/resources/lib/codex/',
2076 'mediawiki.skin.codex-design-tokens/' => $codexDevDir !== null ?
2077 "$codexDevDir/packages/codex-design-tokens/dist/" :
2078 MW_INSTALL_PATH . '/resources/lib/codex-design-tokens/',
2079 '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
2080 throw new RuntimeException(
2081 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2082 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2083 );
2084 }
2085 ];
2086 foreach ( $importMap as $importPath => $substPath ) {
2087 if ( str_starts_with( $path, $importPath ) ) {
2088 $restOfPath = substr( $path, strlen( $importPath ) );
2089 if ( is_callable( $substPath ) ) {
2090 $resolvedPath = call_user_func( $substPath, $restOfPath );
2091 } else {
2092 $filePath = $substPath . $restOfPath;
2093
2094 $resolvedPath = null;
2095 if ( file_exists( $filePath ) ) {
2096 $resolvedPath = $filePath;
2097 } elseif ( file_exists( "$filePath.less" ) ) {
2098 $resolvedPath = "$filePath.less";
2099 }
2100 }
2101
2102 if ( $resolvedPath !== null ) {
2103 return [
2104 Less_Environment::normalizePath( $resolvedPath ),
2105 Less_Environment::normalizePath( dirname( $path ) )
2106 ];
2107 } else {
2108 break;
2109 }
2110 }
2111 }
2112 return [ null, null ];
2113 };
2114 $parser->SetImportDirs( $formattedImportDirs );
2115
2116 return $parser;
2117 }
2118
2132 public function expandUrl( string $base, string $url ): string {
2133 // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2134 $isProtoRelative = strpos( $base, '//' ) === 0;
2135 if ( $isProtoRelative ) {
2136 $base = "https:$base";
2137 }
2138 // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2139 $baseUrl = new Net_URL2( $base );
2140 $ret = $baseUrl->resolve( $url );
2141 if ( $isProtoRelative ) {
2142 $ret->setScheme( false );
2143 }
2144 return $ret->getURL();
2145 }
2146
2164 public static function filter( $filter, $data, array $options = [] ) {
2165 if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2166 return $data;
2167 }
2168
2169 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2170 return self::applyFilter( $filter, $data ) ?? $data;
2171 }
2172
2173 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2174 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
2175 ->getLocalServerInstance( CACHE_ANYTHING );
2176
2177 $key = $cache->makeGlobalKey(
2178 'resourceloader-filter',
2179 $filter,
2180 self::CACHE_VERSION,
2181 md5( $data )
2182 );
2183
2184 $status = 'hit';
2185 $incKey = "resourceloader_cache.$filter.$status";
2186 $result = $cache->getWithSetCallback(
2187 $key,
2188 BagOStuff::TTL_DAY,
2189 static function () use ( $filter, $data, &$incKey, &$status ) {
2190 $status = 'miss';
2191 $incKey = "resourceloader_cache.$filter.$status";
2192 return self::applyFilter( $filter, $data );
2193 }
2194 );
2195 $statsFactory->getCounter( 'resourceloader_cache_total' )
2196 ->setLabel( 'type', $filter )
2197 ->setLabel( 'status', $status )
2198 ->copyToStatsdAt( [ $incKey ] )
2199 ->increment();
2200
2201 // Use $data on cache failure
2202 return $result ?? $data;
2203 }
2204
2210 private static function applyFilter( $filter, $data ) {
2211 $data = trim( $data );
2212 if ( $data ) {
2213 try {
2214 $data = ( $filter === 'minify-css' )
2215 ? CSSMin::minify( $data )
2216 : JavaScriptMinifier::minify( $data );
2217 } catch ( TimeoutException $e ) {
2218 throw $e;
2219 } catch ( Exception $e ) {
2221 return null;
2222 }
2223 }
2224 return $data;
2225 }
2226
2238 public static function getUserDefaults(
2239 Context $context,
2240 HookContainer $hookContainer,
2241 UserOptionsLookup $userOptionsLookup
2242 ): array {
2243 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2244 $keysToExclude = [];
2245 $hookRunner = new HookRunner( $hookContainer );
2246 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2247 foreach ( $keysToExclude as $excludedKey ) {
2248 unset( $defaultOptions[ $excludedKey ] );
2249 }
2250 return $defaultOptions;
2251 }
2252
2261 public static function getSiteConfigSettings(
2262 Context $context, Config $conf
2263 ): array {
2264 $services = MediaWikiServices::getInstance();
2265 // Namespace related preparation
2266 // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2267 // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2268 $contLang = $services->getContentLanguage();
2269 $namespaceIds = $contLang->getNamespaceIds();
2270 $caseSensitiveNamespaces = [];
2271 $nsInfo = $services->getNamespaceInfo();
2272 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2273 $namespaceIds[$contLang->lc( $name )] = $index;
2274 if ( !$nsInfo->isCapitalized( $index ) ) {
2275 $caseSensitiveNamespaces[] = $index;
2276 }
2277 }
2278
2279 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2280
2281 // Build list of variables
2282 $skin = $context->getSkin();
2283
2284 // Start of supported and stable config vars (for use by extensions/gadgets).
2285 $vars = [
2286 'debug' => $context->getDebug(),
2287 'skin' => $skin,
2288 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2289 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2290 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2291 'wgScript' => $conf->get( MainConfigNames::Script ),
2292 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2293 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2294 'wgServer' => $conf->get( MainConfigNames::Server ),
2295 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2296 'wgUserLanguage' => $context->getLanguage(),
2297 'wgContentLanguage' => $contLang->getCode(),
2298 'wgVersion' => MW_VERSION,
2299 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2300 'wgNamespaceIds' => $namespaceIds,
2301 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2302 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2303 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2304 'wgWikiID' => WikiMap::getCurrentWikiId(),
2305 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2306 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2307 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2308 ];
2309 // End of stable config vars.
2310
2311 // Internal variables for use by MediaWiki core and/or ResourceLoader.
2312 $vars += [
2313 // @internal For mediawiki.widgets
2314 'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(),
2315 // @internal For mediawiki.page.watch
2316 // Force object to avoid "empty" associative array from
2317 // becoming [] instead of {} in JS (T36604)
2318 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2319 // @internal For mediawiki.language
2320 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2321 // @internal For mediawiki.Title
2322 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2323 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2324 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2325 ];
2326
2327 ( new HookRunner( $services->getHookContainer() ) )
2328 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2329
2330 return $vars;
2331 }
2332
2337 public function getErrors() {
2338 return $this->errors;
2339 }
2340}
const CACHE_ANYTHING
Definition Defines.php:86
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:37
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.
global $wgRequest
Definition Setup.php:419
array $params
The job parameters.
const MW_ENTRY_POINT
Definition api.php:35
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.
Load JSON files, and uses a Processor to extract information.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
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:496
getImageObj()
If this is a request for an image, get the Image object.
Definition Context.php:377
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:667
static getVary(Context $context)
Get vary string.
Definition Module.php:1147
shouldEmbedModule(Context $context)
Check whether this module should be embedded rather than linked.
Definition Module.php:1062
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.
__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
Track per-module dependency file paths that are expensive to mass compute.
Track per-module file dependencies in object cache via BagOStuff.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:89
Store data in a memory for the current request/process only.
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