MediaWiki master
ResourceLoader.php
Go to the documentation of this file.
1<?php
24
25use Exception;
27use HttpStatus;
28use InvalidArgumentException;
29use Less_Environment;
30use Less_Parser;
31use 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 ' . 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 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 );
1904 self::$debugMode = Context::debugFromString( $str );
1905 }
1906 return self::$debugMode;
1907 }
1908
1919 public static function clearCache() {
1920 self::$debugMode = null;
1921 }
1922
1932 public function createLoaderURL( $source, Context $context,
1933 array $extraQuery = []
1934 ) {
1935 $query = self::createLoaderQuery( $context, $extraQuery );
1936 $script = $this->getLoadScript( $source );
1937
1938 return wfAppendQuery( $script, $query );
1939 }
1940
1950 protected static function createLoaderQuery(
1951 Context $context, array $extraQuery = []
1952 ) {
1953 return self::makeLoaderQuery(
1954 $context->getModules(),
1955 $context->getLanguage(),
1956 $context->getSkin(),
1957 $context->getUser(),
1958 $context->getVersion(),
1959 $context->getDebug(),
1960 $context->getOnly(),
1961 $context->getRequest()->getBool( 'printable' ),
1962 null,
1963 $extraQuery
1964 );
1965 }
1966
1983 public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
1984 $version = null, $debug = Context::DEBUG_OFF, $only = null,
1985 $printable = false, $handheld = null, array $extraQuery = []
1986 ) {
1987 $query = [
1988 'modules' => self::makePackedModulesString( $modules ),
1989 ];
1990 // Keep urls short by omitting query parameters that
1991 // match the defaults assumed by Context.
1992 // Note: This relies on the defaults either being insignificant or forever constant,
1993 // as otherwise cached urls could change in meaning when the defaults change.
1994 if ( $lang !== Context::DEFAULT_LANG ) {
1995 $query['lang'] = $lang;
1996 }
1997 if ( $skin !== Context::DEFAULT_SKIN ) {
1998 $query['skin'] = $skin;
1999 }
2000 if ( $debug !== Context::DEBUG_OFF ) {
2001 $query['debug'] = strval( $debug );
2002 }
2003 if ( $user !== null ) {
2004 $query['user'] = $user;
2005 }
2006 if ( $version !== null ) {
2007 $query['version'] = $version;
2008 }
2009 if ( $only !== null ) {
2010 $query['only'] = $only;
2011 }
2012 if ( $printable ) {
2013 $query['printable'] = 1;
2014 }
2015 foreach ( $extraQuery as $name => $value ) {
2016 $query[$name] = $value;
2017 }
2018
2019 // Make queries uniform in order
2020 ksort( $query );
2021 return $query;
2022 }
2023
2033 public static function isValidModuleName( $moduleName ) {
2034 $len = strlen( $moduleName );
2035 return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
2036 }
2037
2048 public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
2049 global $IP;
2050 // When called from the installer, it is possible that a required PHP extension
2051 // is missing (at least for now; see T49564). If this is the case, throw an
2052 // exception (caught by the installer) to prevent a fatal error later on.
2053 if ( !class_exists( Less_Parser::class ) ) {
2054 throw new RuntimeException( 'MediaWiki requires the less.php parser' );
2055 }
2056
2057 $importDirs[] = "$IP/resources/src/mediawiki.less";
2058
2059 $parser = new Less_Parser;
2060 $parser->ModifyVars( $vars );
2061 $parser->SetOption( 'relativeUrls', false );
2062 $parser->SetOption( 'math', 'always' );
2063
2064 // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2065 $formattedImportDirs = array_fill_keys( $importDirs, '' );
2066 // Add a callback to the import dirs array for path remapping
2067 $formattedImportDirs[] = static function ( $path ) {
2068 global $IP;
2069 $importMap = [
2070 '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
2071 'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
2072 '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
2073 throw new RuntimeException(
2074 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2075 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2076 );
2077 }
2078 ];
2079 foreach ( $importMap as $importPath => $substPath ) {
2080 if ( str_starts_with( $path, $importPath ) ) {
2081 $restOfPath = substr( $path, strlen( $importPath ) );
2082 if ( is_callable( $substPath ) ) {
2083 $resolvedPath = call_user_func( $substPath, $restOfPath );
2084 } else {
2085 $filePath = $substPath . $restOfPath;
2086
2087 $resolvedPath = null;
2088 if ( file_exists( $filePath ) ) {
2089 $resolvedPath = $filePath;
2090 } elseif ( file_exists( "$filePath.less" ) ) {
2091 $resolvedPath = "$filePath.less";
2092 }
2093 }
2094
2095 if ( $resolvedPath !== null ) {
2096 return [
2097 Less_Environment::normalizePath( $resolvedPath ),
2098 Less_Environment::normalizePath( dirname( $path ) )
2099 ];
2100 } else {
2101 break;
2102 }
2103 }
2104 }
2105 return [ null, null ];
2106 };
2107 $parser->SetImportDirs( $formattedImportDirs );
2108
2109 return $parser;
2110 }
2111
2125 public function expandUrl( string $base, string $url ): string {
2126 // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2127 $isProtoRelative = strpos( $base, '//' ) === 0;
2128 if ( $isProtoRelative ) {
2129 $base = "https:$base";
2130 }
2131 // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2132 $baseUrl = new Net_URL2( $base );
2133 $ret = $baseUrl->resolve( $url );
2134 if ( $isProtoRelative ) {
2135 $ret->setScheme( false );
2136 }
2137 return $ret->getURL();
2138 }
2139
2157 public static function filter( $filter, $data, array $options = [] ) {
2158 if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2159 return $data;
2160 }
2161
2162 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2163 return self::applyFilter( $filter, $data ) ?? $data;
2164 }
2165
2166 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2167 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
2168 ->getLocalServerInstance( CACHE_ANYTHING );
2169
2170 $key = $cache->makeGlobalKey(
2171 'resourceloader-filter',
2172 $filter,
2173 self::CACHE_VERSION,
2174 md5( $data )
2175 );
2176
2177 $status = 'hit';
2178 $incKey = "resourceloader_cache.$filter.$status";
2179 $result = $cache->getWithSetCallback(
2180 $key,
2181 BagOStuff::TTL_DAY,
2182 static function () use ( $filter, $data, &$incKey, &$status ) {
2183 $status = 'miss';
2184 $incKey = "resourceloader_cache.$filter.$status";
2185 return self::applyFilter( $filter, $data );
2186 }
2187 );
2188 $statsFactory->getCounter( 'resourceloader_cache_total' )
2189 ->setLabel( 'type', $filter )
2190 ->setLabel( 'status', $status )
2191 ->copyToStatsdAt( [ $incKey ] )
2192 ->increment();
2193
2194 // Use $data on cache failure
2195 return $result ?? $data;
2196 }
2197
2203 private static function applyFilter( $filter, $data ) {
2204 $data = trim( $data );
2205 if ( $data ) {
2206 try {
2207 $data = ( $filter === 'minify-css' )
2208 ? CSSMin::minify( $data )
2209 : JavaScriptMinifier::minify( $data );
2210 } catch ( TimeoutException $e ) {
2211 throw $e;
2212 } catch ( Exception $e ) {
2214 return null;
2215 }
2216 }
2217 return $data;
2218 }
2219
2231 public static function getUserDefaults(
2232 Context $context,
2233 HookContainer $hookContainer,
2234 UserOptionsLookup $userOptionsLookup
2235 ): array {
2236 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2237 $keysToExclude = [];
2238 $hookRunner = new HookRunner( $hookContainer );
2239 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2240 foreach ( $keysToExclude as $excludedKey ) {
2241 unset( $defaultOptions[ $excludedKey ] );
2242 }
2243 return $defaultOptions;
2244 }
2245
2254 public static function getSiteConfigSettings(
2255 Context $context, Config $conf
2256 ): array {
2257 $services = MediaWikiServices::getInstance();
2258 // Namespace related preparation
2259 // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2260 // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2261 $contLang = $services->getContentLanguage();
2262 $namespaceIds = $contLang->getNamespaceIds();
2263 $caseSensitiveNamespaces = [];
2264 $nsInfo = $services->getNamespaceInfo();
2265 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2266 $namespaceIds[$contLang->lc( $name )] = $index;
2267 if ( !$nsInfo->isCapitalized( $index ) ) {
2268 $caseSensitiveNamespaces[] = $index;
2269 }
2270 }
2271
2272 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2273
2274 // Build list of variables
2275 $skin = $context->getSkin();
2276
2277 // Start of supported and stable config vars (for use by extensions/gadgets).
2278 $vars = [
2279 'debug' => $context->getDebug(),
2280 'skin' => $skin,
2281 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2282 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2283 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2284 'wgScript' => $conf->get( MainConfigNames::Script ),
2285 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2286 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2287 'wgServer' => $conf->get( MainConfigNames::Server ),
2288 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2289 'wgUserLanguage' => $context->getLanguage(),
2290 'wgContentLanguage' => $contLang->getCode(),
2291 'wgVersion' => MW_VERSION,
2292 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2293 'wgNamespaceIds' => $namespaceIds,
2294 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2295 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2296 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2297 'wgWikiID' => WikiMap::getCurrentWikiId(),
2298 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2299 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2300 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2301 ];
2302 // End of stable config vars.
2303
2304 // Internal variables for use by MediaWiki core and/or ResourceLoader.
2305 $vars += [
2306 // @internal For mediawiki.widgets
2307 'wgUrlProtocols' => wfUrlProtocols(),
2308 // @internal For mediawiki.page.watch
2309 // Force object to avoid "empty" associative array from
2310 // becoming [] instead of {} in JS (T36604)
2311 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2312 // @internal For mediawiki.language
2313 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2314 // @internal For mediawiki.Title
2315 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2316 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2317 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2318 ];
2319
2320 ( new HookRunner( $services->getHookContainer() ) )
2321 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2322
2323 return $vars;
2324 }
2325
2330 public function getErrors() {
2331 return $this->errors;
2332 }
2333}
const CACHE_ANYTHING
Definition Defines.php:86
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:37
wfUrlProtocols( $includeProtocolRelative=true)
Returns a partial regular expression of recognized URL protocols, e.g.
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
wfResetOutputBuffers( $resetGzipEncoding=true)
Clear away any user-level output buffers, discarding contents.
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:100
global $wgRequest
Definition Setup.php:417
array $params
The job parameters.
const MW_ENTRY_POINT
Definition api.php:35
Load JSON files, and uses a Processor to extract information.
Handler class for MWExceptions.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
Handle database storage of comments such as edit summaries and log reasons.
Defer callable updates to run later in the PHP process.
A wrapper class which causes Html::encodeJsVar() and Html::encodeJsCall() (as well as their Xml::* co...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A class containing constants representing the names of configuration variables.
const ResourceLoaderEnableSourceMapLinks
Name constant for the ResourceLoaderEnableSourceMapLinks setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Class for tracking request-level classification information for profiling/stats/logging.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
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:497
getImageObj()
If this is a request for an image, get the Image object.
Definition Context.php:378
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:79
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.
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:88
Simple store for keeping values in an associative array for the current process.
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