MediaWiki master
ResourceLoader.php
Go to the documentation of this file.
1<?php
24
25use BagOStuff;
26use Exception;
29use HttpStatus;
30use InvalidArgumentException;
31use Less_Environment;
32use Less_Parser;
33use LogicException;
51use Net_URL2;
52use ObjectCache;
53use Psr\Log\LoggerAwareInterface;
54use Psr\Log\LoggerInterface;
55use Psr\Log\NullLogger;
56use RuntimeException;
57use stdClass;
58use Throwable;
59use UnexpectedValueException;
62use Wikimedia\Minify\CSSMin;
63use Wikimedia\Minify\IdentityMinifierState;
64use Wikimedia\Minify\IndexMap;
65use Wikimedia\Minify\IndexMapOffset;
66use Wikimedia\Minify\JavaScriptMapperState;
67use Wikimedia\Minify\JavaScriptMinifier;
68use Wikimedia\Minify\JavaScriptMinifierState;
69use Wikimedia\Minify\MinifierState;
70use Wikimedia\RequestTimeout\TimeoutException;
71use Wikimedia\ScopedCallback;
73use Wikimedia\Timestamp\ConvertibleTimestamp;
74use Wikimedia\WrappedString;
75
96class ResourceLoader implements LoggerAwareInterface {
98 public const CACHE_VERSION = 9;
100 public const FILTER_NOMIN = '/*@nomin*/';
101
103 private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
105 private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR;
107 private const MAXAGE_RECOVER = 60;
108
110 protected static $debugMode = null;
111
113 private $config;
115 private $blobStore;
117 private $depStore;
119 private $logger;
121 private $hookContainer;
123 private $srvCache;
125 private $statsFactory;
127 private $maxageVersioned;
129 private $maxageUnversioned;
130
132 private $modules = [];
134 private $moduleInfos = [];
136 private $testModuleNames = [];
138 private $sources = [];
140 protected $errors = [];
145 protected $extraHeaders = [];
147 private $depStoreUpdateBuffer = [];
152 private $moduleSkinStyles = [];
153
174 public function __construct(
175 Config $config,
176 LoggerInterface $logger = null,
177 DependencyStore $tracker = null,
178 array $params = []
179 ) {
180 $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60;
181 $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60;
182
183 $this->config = $config;
184 $this->logger = $logger ?: new NullLogger();
185
186 $services = MediaWikiServices::getInstance();
187 $this->hookContainer = $services->getHookContainer();
188
189 $this->srvCache = $services->getLocalServerObjectCache();
190 $this->statsFactory = $services->getStatsFactory();
191
192 // Add 'local' source first
193 $this->addSource( 'local', $params['loadScript'] ?? '/load.php' );
194
195 // Special module that always exists
196 $this->register( 'startup', [ 'class' => StartUpModule::class ] );
197
198 $this->setMessageBlobStore(
199 new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
200 );
201
202 $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
203 $this->setDependencyStore( $tracker );
204 }
205
209 public function getConfig() {
210 return $this->config;
211 }
212
217 public function setLogger( LoggerInterface $logger ) {
218 $this->logger = $logger;
219 }
220
225 public function getLogger() {
226 return $this->logger;
227 }
228
233 public function getMessageBlobStore() {
234 return $this->blobStore;
235 }
236
241 public function setMessageBlobStore( MessageBlobStore $blobStore ) {
242 $this->blobStore = $blobStore;
243 }
244
249 public function setDependencyStore( DependencyStore $tracker ) {
250 $this->depStore = $tracker;
251 }
252
257 public function setModuleSkinStyles( array $moduleSkinStyles ) {
258 $this->moduleSkinStyles = $moduleSkinStyles;
259 }
260
272 public function register( $name, array $info = null ) {
273 // Allow multiple modules to be registered in one call
274 $registrations = is_array( $name ) ? $name : [ $name => $info ];
275 foreach ( $registrations as $name => $info ) {
276 // Warn on duplicate registrations
277 if ( isset( $this->moduleInfos[$name] ) ) {
278 // A module has already been registered by this name
279 $this->logger->warning(
280 'ResourceLoader duplicate registration warning. ' .
281 'Another module has already been registered as ' . $name
282 );
283 }
284
285 // Check validity
286 if ( !self::isValidModuleName( $name ) ) {
287 throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
288 . "see ResourceLoader::isValidModuleName()" );
289 }
290 if ( !is_array( $info ) ) {
291 throw new InvalidArgumentException(
292 'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
293 );
294 }
295
296 // Attach module
297 $this->moduleInfos[$name] = $info;
298 }
299 }
300
305 public function registerTestModules(): void {
306 $extRegistry = ExtensionRegistry::getInstance();
307 $testModules = $extRegistry->getAttribute( 'QUnitTestModules' );
308
309 $testModuleNames = [];
310 foreach ( $testModules as $name => &$module ) {
311 // Turn any single-module dependency into an array
312 if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
313 $module['dependencies'] = [ $module['dependencies'] ];
314 }
315
316 // Ensure the testrunner loads before any tests
317 $module['dependencies'][] = 'mediawiki.qunit-testrunner';
318
319 // Keep track of the modules to load on SpecialJavaScriptTest
320 $testModuleNames[] = $name;
321 }
322
323 // Core test modules (their names have further precedence).
324 $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules;
325 $testModuleNames[] = 'test.MediaWiki';
326
327 $this->register( $testModules );
328 $this->testModuleNames = $testModuleNames;
329 }
330
341 public function addSource( $sources, $loadUrl = null ) {
342 if ( !is_array( $sources ) ) {
343 $sources = [ $sources => $loadUrl ];
344 }
345 foreach ( $sources as $id => $source ) {
346 // Disallow duplicates
347 if ( isset( $this->sources[$id] ) ) {
348 throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
349 }
350
351 // Support: MediaWiki 1.24 and earlier
352 if ( is_array( $source ) ) {
353 if ( !isset( $source['loadScript'] ) ) {
354 throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
355 }
356 $source = $source['loadScript'];
357 }
358
359 $this->sources[$id] = $source;
360 }
361 }
362
366 public function getModuleNames() {
367 return array_keys( $this->moduleInfos );
368 }
369
377 public function getTestSuiteModuleNames() {
378 return $this->testModuleNames;
379 }
380
388 public function isModuleRegistered( $name ) {
389 return isset( $this->moduleInfos[$name] );
390 }
391
403 public function getModule( $name ) {
404 if ( !isset( $this->modules[$name] ) ) {
405 if ( !isset( $this->moduleInfos[$name] ) ) {
406 // No such module
407 return null;
408 }
409 // Construct the requested module object
410 $info = $this->moduleInfos[$name];
411 if ( isset( $info['factory'] ) ) {
413 $object = call_user_func( $info['factory'], $info );
414 } else {
415 $class = $info['class'] ?? FileModule::class;
417 $object = new $class( $info );
418 }
419 $object->setConfig( $this->getConfig() );
420 $object->setLogger( $this->logger );
421 $object->setHookContainer( $this->hookContainer );
422 $object->setName( $name );
423 $object->setDependencyAccessCallbacks(
424 [ $this, 'loadModuleDependenciesInternal' ],
425 [ $this, 'saveModuleDependenciesInternal' ]
426 );
427 $object->setSkinStylesOverride( $this->moduleSkinStyles );
428 $this->modules[$name] = $object;
429 }
430
431 return $this->modules[$name];
432 }
433
440 public function preloadModuleInfo( array $moduleNames, Context $context ) {
441 // Load all tracked indirect file dependencies for the modules
442 $vary = Module::getVary( $context );
443 $entitiesByModule = [];
444 foreach ( $moduleNames as $moduleName ) {
445 $entitiesByModule[$moduleName] = "$moduleName|$vary";
446 }
447 $depsByEntity = $this->depStore->retrieveMulti(
448 self::RL_DEP_STORE_PREFIX,
449 $entitiesByModule
450 );
451 // Inject the indirect file dependencies for all the modules
452 foreach ( $moduleNames as $moduleName ) {
453 $module = $this->getModule( $moduleName );
454 if ( $module ) {
455 $entity = $entitiesByModule[$moduleName];
456 $deps = $depsByEntity[$entity];
457 $paths = Module::expandRelativePaths( $deps['paths'] );
458 $module->setFileDependencies( $context, $paths );
459 }
460 }
461
462 WikiModule::preloadTitleInfo( $context, $moduleNames );
463
464 // Prime in-object cache for message blobs for modules with messages
465 $modulesWithMessages = [];
466 foreach ( $moduleNames as $moduleName ) {
467 $module = $this->getModule( $moduleName );
468 if ( $module && $module->getMessages() ) {
469 $modulesWithMessages[$moduleName] = $module;
470 }
471 }
472 // Prime in-object cache for message blobs for modules with messages
473 $lang = $context->getLanguage();
474 $store = $this->getMessageBlobStore();
475 $blobs = $store->getBlobs( $modulesWithMessages, $lang );
476 foreach ( $blobs as $moduleName => $blob ) {
477 $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
478 }
479 }
480
487 public function loadModuleDependenciesInternal( $moduleName, $variant ) {
488 $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
489
490 return Module::expandRelativePaths( $deps['paths'] );
491 }
492
500 public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
501 $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
502 $entity = "$moduleName|$variant";
503
504 if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
505 // Dependency store needs to be updated with the new path list
506 if ( $paths ) {
507 $deps = $this->depStore->newEntityDependencies( $paths, time() );
508 $this->depStoreUpdateBuffer[$entity] = $deps;
509 } else {
510 $this->depStoreUpdateBuffer[$entity] = null;
511 }
512 }
513
514 // If paths were unchanged, leave the dependency store unchanged also.
515 // The entry will eventually expire, after which we will briefly issue an incomplete
516 // version hash for a 5-min startup window, the module then recomputes and rediscovers
517 // the paths and arrive at the same module version hash once again. It will churn
518 // part of the browser cache once, for clients connecting during that window.
519
520 if ( !$hasPendingUpdate ) {
521 DeferredUpdates::addCallableUpdate( function () {
522 $updatesByEntity = $this->depStoreUpdateBuffer;
523 $this->depStoreUpdateBuffer = [];
524 $cache = ObjectCache::getLocalClusterInstance();
525
526 $scopeLocks = [];
527 $depsByEntity = [];
528 $entitiesUnreg = [];
529 foreach ( $updatesByEntity as $entity => $update ) {
530 $lockKey = $cache->makeKey( 'rl-deps', $entity );
531 $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
532 if ( !$scopeLocks[$entity] ) {
533 // avoid duplicate write request slams (T124649)
534 // the lock must be specific to the current wiki (T247028)
535 continue;
536 }
537 if ( $update === null ) {
538 $entitiesUnreg[] = $entity;
539 } else {
540 $depsByEntity[$entity] = $update;
541 }
542 }
543
544 $ttl = self::RL_MODULE_DEP_TTL;
545 $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
546 $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
547 } );
548 }
549 }
550
556 public function getSources() {
557 return $this->sources;
558 }
559
568 public function getLoadScript( $source ) {
569 if ( !isset( $this->sources[$source] ) ) {
570 throw new UnexpectedValueException( "Unknown source '$source'" );
571 }
572 return $this->sources[$source];
573 }
574
578 public const HASH_LENGTH = 5;
579
642 public static function makeHash( $value ) {
643 $hash = hash( 'fnv132', $value );
644 // The base_convert will pad it (if too short),
645 // then substr() will trim it (if too long).
646 return substr(
647 \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
648 0,
649 self::HASH_LENGTH
650 );
651 }
652
662 public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
663 MWExceptionHandler::logException( $e );
664 $this->logger->warning(
665 $msg,
666 $context + [ 'exception' => $e ]
667 );
668 $this->errors[] = self::formatExceptionNoComment( $e );
669 }
670
679 public function getCombinedVersion( Context $context, array $moduleNames ) {
680 if ( !$moduleNames ) {
681 return '';
682 }
683 $hashes = [];
684 foreach ( $moduleNames as $module ) {
685 try {
686 $hash = $this->getModule( $module )->getVersionHash( $context );
687 } catch ( TimeoutException $e ) {
688 throw $e;
689 } catch ( Exception $e ) {
690 // If modules fail to compute a version, don't fail the request (T152266)
691 // and still compute versions of other modules.
692 $this->outputErrorAndLog( $e,
693 'Calculating version for "{module}" failed: {exception}',
694 [
695 'module' => $module,
696 ]
697 );
698 $hash = '';
699 }
700 $hashes[] = $hash;
701 }
702 return self::makeHash( implode( '', $hashes ) );
703 }
704
719 public function makeVersionQuery( Context $context, array $modules ) {
720 // As of MediaWiki 1.28, the server and client use the same algorithm for combining
721 // version hashes. There is no technical reason for this to be same, and for years the
722 // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
723 // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
724 // query parameter), then this method must continue to match the JS one.
725 $filtered = [];
726 foreach ( $modules as $name ) {
727 if ( !$this->getModule( $name ) ) {
728 // If a versioned request contains a missing module, the version is a mismatch
729 // as the client considered a module (and version) we don't have.
730 return '';
731 }
732 $filtered[] = $name;
733 }
734 return $this->getCombinedVersion( $context, $filtered );
735 }
736
742 public function respond( Context $context ) {
743 // Buffer output to catch warnings. Normally we'd use ob_clean() on the
744 // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
745 // is used: ob_clean() will clear the GZIP header in that case and it won't come
746 // back for subsequent output, resulting in invalid GZIP. So we have to wrap
747 // the whole thing in our own output buffer to be sure the active buffer
748 // doesn't use ob_gzhandler.
749 // See https://bugs.php.net/bug.php?id=36514
750 ob_start();
751
752 $this->errors = [];
753 $responseTime = $this->measureResponseTime();
754 ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' );
755
756 // Find out which modules are missing and instantiate the others
757 $modules = [];
758 $missing = [];
759 foreach ( $context->getModules() as $name ) {
760 $module = $this->getModule( $name );
761 if ( $module ) {
762 // Do not allow private modules to be loaded from the web.
763 // This is a security issue, see T36907.
764 if ( $module->getGroup() === Module::GROUP_PRIVATE ) {
765 // Not a serious error, just means something is trying to access it (T101806)
766 $this->logger->debug( "Request for private module '$name' denied" );
767 $this->errors[] = "Cannot build private module \"$name\"";
768 continue;
769 }
770 $modules[$name] = $module;
771 } else {
772 $missing[] = $name;
773 }
774 }
775
776 try {
777 // Preload for getCombinedVersion() and for batch makeModuleResponse()
778 $this->preloadModuleInfo( array_keys( $modules ), $context );
779 } catch ( TimeoutException $e ) {
780 throw $e;
781 } catch ( Exception $e ) {
782 $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
783 }
784
785 // Combine versions to propagate cache invalidation
786 $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
787
788 // See RFC 2616 § 3.11 Entity Tags
789 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
790 $etag = 'W/"' . $versionHash . '"';
791
792 // Try the client-side cache first
793 if ( $this->tryRespondNotModified( $context, $etag ) ) {
794 return; // output handled (buffers cleared)
795 }
796
797 if ( $context->isSourceMap() ) {
798 // In source map mode, a version mismatch should be a 404
799 if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) {
800 ob_end_clean();
801 $this->sendSourceMapVersionMismatch( $versionHash );
802 return;
803 }
804 // No source maps for images, only=styles requests, or debug mode
805 if ( $context->getImage()
806 || $context->getOnly() === 'styles'
807 || $context->getDebug()
808 ) {
809 ob_end_clean();
810 $this->sendSourceMapTypeNotImplemented();
811 return;
812 }
813 }
814 // Emit source map header if supported (inverse of the above check)
816 && !$context->getImageObj()
817 && !$context->isSourceMap()
818 && $context->shouldIncludeScripts()
819 && !$context->getDebug()
820 ) {
821 $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash );
822 }
823
824 // Generate a response
825 $response = $this->makeModuleResponse( $context, $modules, $missing );
826
827 // Capture any PHP warnings from the output buffer and append them to the
828 // error list if we're in debug mode.
829 if ( $context->getDebug() ) {
830 $warnings = ob_get_contents();
831 if ( strlen( $warnings ) ) {
832 $this->errors[] = $warnings;
833 }
834 }
835
836 $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
837
838 // Remove the output buffer and output the response
839 ob_end_clean();
840
841 if ( $context->getImageObj() && $this->errors ) {
842 // We can't show both the error messages and the response when it's an image.
843 $response = implode( "\n\n", $this->errors );
844 } elseif ( $this->errors ) {
845 $errorText = implode( "\n\n", $this->errors );
846 $errorResponse = self::makeComment( $errorText );
847 if ( $context->shouldIncludeScripts() ) {
848 $errorResponse .= 'if (window.console && console.error) { console.error('
849 . $context->encodeJson( $errorText )
850 . "); }\n";
851 // Append the error info to the response
852 // We used to prepend it, but that would corrupt the source map
853 $response .= $errorResponse;
854 } else {
855 // For styles we can still prepend
856 $response = $errorResponse . $response;
857 }
858 }
859
860 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
2063 // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
2064 $formattedImportDirs = array_fill_keys( $importDirs, '' );
2065 // Add a callback to the import dirs array for path remapping
2066 $formattedImportDirs[] = static function ( $path ) {
2067 global $IP;
2068 $importMap = [
2069 '@wikimedia/codex-icons/' => "$IP/resources/lib/codex-icons/",
2070 'mediawiki.skin.codex-design-tokens/' => "$IP/resources/lib/codex-design-tokens/",
2071 '@wikimedia/codex-design-tokens/' => static function ( $unused_path ) {
2072 throw new RuntimeException(
2073 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
2074 "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
2075 );
2076 }
2077 ];
2078 foreach ( $importMap as $importPath => $substPath ) {
2079 if ( str_starts_with( $path, $importPath ) ) {
2080 $restOfPath = substr( $path, strlen( $importPath ) );
2081 if ( is_callable( $substPath ) ) {
2082 $resolvedPath = call_user_func( $substPath, $restOfPath );
2083 } else {
2084 $filePath = $substPath . $restOfPath;
2085
2086 $resolvedPath = null;
2087 if ( file_exists( $filePath ) ) {
2088 $resolvedPath = $filePath;
2089 } elseif ( file_exists( "$filePath.less" ) ) {
2090 $resolvedPath = "$filePath.less";
2091 }
2092 }
2093
2094 if ( $resolvedPath !== null ) {
2095 return [
2096 Less_Environment::normalizePath( $resolvedPath ),
2097 Less_Environment::normalizePath( dirname( $path ) )
2098 ];
2099 } else {
2100 break;
2101 }
2102 }
2103 }
2104 return [ null, null ];
2105 };
2106 $parser->SetImportDirs( $formattedImportDirs );
2107
2108 return $parser;
2109 }
2110
2124 public function expandUrl( string $base, string $url ): string {
2125 // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
2126 $isProtoRelative = strpos( $base, '//' ) === 0;
2127 if ( $isProtoRelative ) {
2128 $base = "https:$base";
2129 }
2130 // Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
2131 $baseUrl = new Net_URL2( $base );
2132 $ret = $baseUrl->resolve( $url );
2133 if ( $isProtoRelative ) {
2134 $ret->setScheme( false );
2135 }
2136 return $ret->getURL();
2137 }
2138
2156 public static function filter( $filter, $data, array $options = [] ) {
2157 if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
2158 return $data;
2159 }
2160
2161 if ( isset( $options['cache'] ) && $options['cache'] === false ) {
2162 return self::applyFilter( $filter, $data ) ?? $data;
2163 }
2164
2165 $statsFactory = MediaWikiServices::getInstance()->getStatsFactory();
2167
2168 $key = $cache->makeGlobalKey(
2169 'resourceloader-filter',
2170 $filter,
2171 self::CACHE_VERSION,
2172 md5( $data )
2173 );
2174
2175 $status = 'hit';
2176 $incKey = "resourceloader_cache.$filter.$status";
2177 $result = $cache->getWithSetCallback(
2178 $key,
2179 BagOStuff::TTL_DAY,
2180 static function () use ( $filter, $data, &$incKey, &$status ) {
2181 $status = 'miss';
2182 $incKey = "resourceloader_cache.$filter.$status";
2183 return self::applyFilter( $filter, $data );
2184 }
2185 );
2186 $statsFactory->getCounter( 'resourceloader_cache_total' )
2187 ->setLabel( 'type', $filter )
2188 ->setLabel( 'status', $status )
2189 ->copyToStatsdAt( [ $incKey ] )
2190 ->increment();
2191
2192 // Use $data on cache failure
2193 return $result ?? $data;
2194 }
2195
2201 private static function applyFilter( $filter, $data ) {
2202 $data = trim( $data );
2203 if ( $data ) {
2204 try {
2205 $data = ( $filter === 'minify-css' )
2206 ? CSSMin::minify( $data )
2207 : JavaScriptMinifier::minify( $data );
2208 } catch ( TimeoutException $e ) {
2209 throw $e;
2210 } catch ( Exception $e ) {
2212 return null;
2213 }
2214 }
2215 return $data;
2216 }
2217
2229 public static function getUserDefaults(
2230 Context $context,
2231 HookContainer $hookContainer,
2232 UserOptionsLookup $userOptionsLookup
2233 ): array {
2234 $defaultOptions = $userOptionsLookup->getDefaultOptions();
2235 $keysToExclude = [];
2236 $hookRunner = new HookRunner( $hookContainer );
2237 $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context );
2238 foreach ( $keysToExclude as $excludedKey ) {
2239 unset( $defaultOptions[ $excludedKey ] );
2240 }
2241 return $defaultOptions;
2242 }
2243
2252 public static function getSiteConfigSettings(
2253 Context $context, Config $conf
2254 ): array {
2255 $services = MediaWikiServices::getInstance();
2256 // Namespace related preparation
2257 // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
2258 // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
2259 $contLang = $services->getContentLanguage();
2260 $namespaceIds = $contLang->getNamespaceIds();
2261 $caseSensitiveNamespaces = [];
2262 $nsInfo = $services->getNamespaceInfo();
2263 foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
2264 $namespaceIds[$contLang->lc( $name )] = $index;
2265 if ( !$nsInfo->isCapitalized( $index ) ) {
2266 $caseSensitiveNamespaces[] = $index;
2267 }
2268 }
2269
2270 $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars );
2271
2272 // Build list of variables
2273 $skin = $context->getSkin();
2274
2275 // Start of supported and stable config vars (for use by extensions/gadgets).
2276 $vars = [
2277 'debug' => $context->getDebug(),
2278 'skin' => $skin,
2279 'stylepath' => $conf->get( MainConfigNames::StylePath ),
2280 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ),
2281 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ),
2282 'wgScript' => $conf->get( MainConfigNames::Script ),
2283 'wgSearchType' => $conf->get( MainConfigNames::SearchType ),
2284 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ),
2285 'wgServer' => $conf->get( MainConfigNames::Server ),
2286 'wgServerName' => $conf->get( MainConfigNames::ServerName ),
2287 'wgUserLanguage' => $context->getLanguage(),
2288 'wgContentLanguage' => $contLang->getCode(),
2289 'wgVersion' => MW_VERSION,
2290 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
2291 'wgNamespaceIds' => $namespaceIds,
2292 'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
2293 'wgSiteName' => $conf->get( MainConfigNames::Sitename ),
2294 'wgDBname' => $conf->get( MainConfigNames::DBname ),
2295 'wgWikiID' => WikiMap::getCurrentWikiId(),
2296 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
2297 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
2298 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ),
2299 ];
2300 // End of stable config vars.
2301
2302 // Internal variables for use by MediaWiki core and/or ResourceLoader.
2303 $vars += [
2304 // @internal For mediawiki.widgets
2305 'wgUrlProtocols' => wfUrlProtocols(),
2306 // @internal For mediawiki.page.watch
2307 // Force object to avoid "empty" associative array from
2308 // becoming [] instead of {} in JS (T36604)
2309 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ),
2310 // @internal For mediawiki.language
2311 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ),
2312 // @internal For mediawiki.Title
2313 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ),
2314 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
2315 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
2316 ];
2317
2318 ( new HookRunner( $services->getHookContainer() ) )
2319 ->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
2320
2321 return $vars;
2322 }
2323
2328 public function getErrors() {
2329 return $this->errors;
2330 }
2331}
const CACHE_ANYTHING
Definition Defines.php:85
const MW_VERSION
The running version of MediaWiki.
Definition Defines.php:36
wfUrlProtocols( $includeProtocolRelative=true)
Returns a partial regular expression of recognized URL protocols, e.g.
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
wfResetOutputBuffers( $resetGzipEncoding=true)
Clear away any user-level output buffers, discarding contents.
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:98
global $wgRequest
Definition Setup.php:415
array $params
The job parameters.
const MW_ENTRY_POINT
Definition api.php:35
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:85
Load JSON files, and uses a Processor to extract information.
Simple store for keeping values in an associative array for the current process.
Handler class for MWExceptions.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
Handle database storage of comments such as edit summaries and log reasons.
Defer callable updates to run later in the PHP process.
A wrapper class which causes Html::encodeJsVar() and Html::encodeJsCall() (as well as their Xml::* co...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A class containing constants representing the names of configuration variables.
const ResourceLoaderEnableSourceMapLinks
Name constant for the ResourceLoaderEnableSourceMapLinks setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Class for tracking request-level classification information for profiling/stats/logging.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
encodeJson( $data)
Wrapper around json_encode that avoids needless escapes, and pretty-prints in debug mode.
Definition Context.php:493
getImageObj()
If this is a request for an image, get the Image object.
Definition Context.php:374
This class generates message blobs for use by ResourceLoader.
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
Definition Module.php:49
static expandRelativePaths(array $filePaths)
Expand directories relative to $IP.
Definition Module.php:663
static getVary(Context $context)
Get vary string.
Definition Module.php:1143
shouldEmbedModule(Context $context)
Check whether this module should be embedded rather than linked.
Definition Module.php:1058
ResourceLoader is a loading system for JavaScript and CSS resources.
getLoadScript( $source)
Get the URL to the load.php endpoint for the given ResourceLoader source.
static makeComment( $text)
Generate a CSS or JS comment block.
respond(Context $context)
Output a response to a load request, including the content-type header.
isModuleRegistered( $name)
Check whether a ResourceLoader module is registered.
loadModuleDependenciesInternal( $moduleName, $variant)
preloadModuleInfo(array $moduleNames, Context $context)
Load information stored in the database and dependency tracking store about modules.
__construct(Config $config, LoggerInterface $logger=null, DependencyStore $tracker=null, array $params=[])
setMessageBlobStore(MessageBlobStore $blobStore)
tryRespondNotModified(Context $context, $etag)
Respond with HTTP 304 Not Modified if appropriate.
static formatExceptionNoComment(Throwable $e)
Handle exception display.
sendResponseHeaders(Context $context, $etag, $errors, array $extra=[])
Send main response headers to the client.
measureResponseTime()
Send stats about the time used to build the response.
setDependencyStore(DependencyStore $tracker)
static makeHash( $value)
Create a hash for module versioning purposes.
array $errors
Errors accumulated during a respond() call.
saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths)
getTestSuiteModuleNames()
Get a list of modules with QUnit tests.
makeModuleResponse(Context $context, array $modules, array $missing=[])
Generate code for a response.
getModule( $name)
Get the Module object for a given module name.
setModuleSkinStyles(array $moduleSkinStyles)
outputErrorAndLog(Exception $e, $msg, array $context=[])
Add an error to the 'errors' array and log it.
makeVersionQuery(Context $context, array $modules)
Get the expected value of the 'version' query parameter.
string[] $extraHeaders
Buffer for extra response headers during a makeModuleResponse() call.
getCombinedVersion(Context $context, array $moduleNames)
Helper method to get and combine versions of multiple modules.
addSource( $sources, $loadUrl=null)
Add a foreign source of modules.
static preloadTitleInfo(Context $context, array $moduleNames)
Batched version of WikiModule::getTitleInfo.
Represents a title within MediaWiki.
Definition Title.php:78
Provides access to user options.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Functions to get cache objects.
static getLocalServerInstance( $fallback=CACHE_NONE)
Track per-module dependency file paths that are expensive to mass compute.
Track per-module file dependencies in object cache via BagOStuff.
StatsFactory Implementation.
getCounter(string $name)
Makes a new CounterMetric or fetches one from cache.
Interface for configuration instances.
Definition Config.php:32
$source
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Ge...
$header