Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.02% |
610 / 772 |
|
60.00% |
39 / 65 |
CRAP | |
0.00% |
0 / 1 |
| ResourceLoader | |
79.02% |
610 / 772 |
|
60.00% |
39 / 65 |
937.65 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
| getConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getMessageBlobStore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMessageBlobStore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setDependencyStore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDependencyStore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setModuleSkinStyles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| registerTestModules | n/a |
0 / 0 |
n/a |
0 / 0 |
4 | |||||
| addSource | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| getModuleNames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTestSuiteModuleNames | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
| isModuleRegistered | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getModule | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| preloadModuleInfo | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
8.03 | |||
| getSources | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLoadScript | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| makeHash | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| outputErrorAndLog | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| getCombinedVersion | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
| makeVersionQuery | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| respond | |
83.61% |
51 / 61 |
|
0.00% |
0 / 1 |
27.75 | |||
| measureResponseTime | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| sendResponseHeaders | |
64.52% |
20 / 31 |
|
0.00% |
0 / 1 |
18.43 | |||
| tryRespondNotModified | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
6.99 | |||
| getSourceMapUrl | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| sendSourceMapVersionMismatch | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| sendSourceMapTypeNotImplemented | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| makeComment | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| formatExceptionNoComment | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| makeModuleResponse | |
86.96% |
40 / 46 |
|
0.00% |
0 / 1 |
23.07 | |||
| getOneModuleResponse | |
98.28% |
57 / 58 |
|
0.00% |
0 / 1 |
11 | |||
| addOneModuleResponse | |
68.09% |
32 / 47 |
|
0.00% |
0 / 1 |
22.31 | |||
| ensureNewline | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| getModulesByMessage | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| addImplementScript | |
94.12% |
32 / 34 |
|
0.00% |
0 / 1 |
9.02 | |||
| addFiles | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| addFileContent | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
7 | |||
| concatenatePlainScripts | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| addPlainScripts | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| isEmptyFileInfos | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| makeCombinedStyles | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
7.18 | |||
| encodeJsonForScript | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| makeLoaderStateScript | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| isEmptyObject | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| trimArray | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
8 | |||
| makeLoaderRegisterScript | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| makeLoaderSourcesScript | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| makeLoaderConditionalScript | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| makeInlineCodeWithModule | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| makeInlineScript | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| makeConfigSetScript | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| makePackedModulesString | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| expandModuleNames | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| inDebugMode | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| clearCache | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
| createLoaderURL | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| createLoaderQuery | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| makeLoaderQuery | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
9.07 | |||
| isValidModuleName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| getLessCompiler | |
89.58% |
43 / 48 |
|
0.00% |
0 / 1 |
11.14 | |||
| filter | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
| applyFilter | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
7.35 | |||
| getUserDefaults | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| getSiteConfigSettings | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
12 | |||
| getErrors | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | * @author Roan Kattouw |
| 6 | * @author Trevor Parscal |
| 7 | */ |
| 8 | |
| 9 | namespace MediaWiki\ResourceLoader; |
| 10 | |
| 11 | use Exception; |
| 12 | use InvalidArgumentException; |
| 13 | use Less_Environment; |
| 14 | use Less_Parser; |
| 15 | use LogicException; |
| 16 | use MediaWiki\CommentStore\CommentStore; |
| 17 | use MediaWiki\Config\Config; |
| 18 | use MediaWiki\Exception\MWExceptionHandler; |
| 19 | use MediaWiki\Exception\MWExceptionRenderer; |
| 20 | use MediaWiki\HookContainer\HookContainer; |
| 21 | use MediaWiki\Html\Html; |
| 22 | use MediaWiki\Html\HtmlJsCode; |
| 23 | use MediaWiki\MainConfigNames; |
| 24 | use MediaWiki\MediaWikiServices; |
| 25 | use MediaWiki\Output\OutputPage; |
| 26 | use MediaWiki\Profiler\ProfilingContext; |
| 27 | use MediaWiki\Registration\ExtensionRegistry; |
| 28 | use MediaWiki\Request\HeaderCallback; |
| 29 | use MediaWiki\Request\WebRequest; |
| 30 | use MediaWiki\Title\Title; |
| 31 | use MediaWiki\User\Options\UserOptionsLookup; |
| 32 | use MediaWiki\WikiMap\WikiMap; |
| 33 | use Psr\Log\LoggerAwareInterface; |
| 34 | use Psr\Log\LoggerInterface; |
| 35 | use Psr\Log\NullLogger; |
| 36 | use RuntimeException; |
| 37 | use stdClass; |
| 38 | use Throwable; |
| 39 | use UnexpectedValueException; |
| 40 | use Wikimedia\DependencyStore\DependencyStore; |
| 41 | use Wikimedia\Http\HttpStatus; |
| 42 | use Wikimedia\Minify\CSSMin; |
| 43 | use Wikimedia\Minify\IdentityMinifierState; |
| 44 | use Wikimedia\Minify\IndexMap; |
| 45 | use Wikimedia\Minify\IndexMapOffset; |
| 46 | use Wikimedia\Minify\JavaScriptMapperState; |
| 47 | use Wikimedia\Minify\JavaScriptMinifier; |
| 48 | use Wikimedia\Minify\JavaScriptMinifierState; |
| 49 | use Wikimedia\Minify\MinifierState; |
| 50 | use Wikimedia\ObjectCache\BagOStuff; |
| 51 | use Wikimedia\ObjectCache\HashBagOStuff; |
| 52 | use Wikimedia\RequestTimeout\TimeoutException; |
| 53 | use Wikimedia\ScopedCallback; |
| 54 | use Wikimedia\Stats\StatsFactory; |
| 55 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
| 56 | use Wikimedia\WrappedString; |
| 57 | |
| 58 | /** |
| 59 | * @defgroup ResourceLoader ResourceLoader |
| 60 | * |
| 61 | * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>. |
| 62 | */ |
| 63 | |
| 64 | /** |
| 65 | * @defgroup ResourceLoaderHooks ResourceLoader Hooks |
| 66 | * @ingroup ResourceLoader |
| 67 | * @ingroup Hooks |
| 68 | */ |
| 69 | |
| 70 | /** |
| 71 | * ResourceLoader is a loading system for JavaScript and CSS resources. |
| 72 | * |
| 73 | * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>. |
| 74 | * |
| 75 | * @ingroup ResourceLoader |
| 76 | * @since 1.17 |
| 77 | */ |
| 78 | class ResourceLoader implements LoggerAwareInterface { |
| 79 | /** @var int */ |
| 80 | public const CACHE_VERSION = 9; |
| 81 | |
| 82 | /** @var int */ |
| 83 | private const MAXAGE_RECOVER = 60; |
| 84 | |
| 85 | /** @var int|null */ |
| 86 | protected static $debugMode = null; |
| 87 | |
| 88 | /** @var Config */ |
| 89 | private $config; |
| 90 | /** @var MessageBlobStore */ |
| 91 | private $blobStore; |
| 92 | /** @var DependencyStore */ |
| 93 | private $depStore; |
| 94 | /** @var LoggerInterface */ |
| 95 | private $logger; |
| 96 | /** @var HookContainer */ |
| 97 | private $hookContainer; |
| 98 | /** @var BagOStuff */ |
| 99 | private $srvCache; |
| 100 | /** @var StatsFactory */ |
| 101 | private $statsFactory; |
| 102 | /** @var int */ |
| 103 | private $maxageVersioned; |
| 104 | /** @var int */ |
| 105 | private $maxageUnversioned; |
| 106 | |
| 107 | /** @var Module[] Map of (module name => Module) */ |
| 108 | private $modules = []; |
| 109 | /** @var array[] Map of (module name => associative info array) */ |
| 110 | private $moduleInfos = []; |
| 111 | /** @var string[] List of module names that contain QUnit tests */ |
| 112 | private $testModuleNames = []; |
| 113 | /** @var string[] Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */ |
| 114 | private $sources = []; |
| 115 | /** @var array Errors accumulated during a respond() call. Exposed for testing. */ |
| 116 | protected $errors = []; |
| 117 | /** |
| 118 | * @var string[] Buffer for extra response headers during a makeModuleResponse() call. |
| 119 | * Exposed for testing. |
| 120 | */ |
| 121 | protected $extraHeaders = []; |
| 122 | /** |
| 123 | * @var array Styles that are skin-specific and supplement or replace the |
| 124 | * default skinStyles of a FileModule. See $wgResourceModuleSkinStyles. |
| 125 | */ |
| 126 | private $moduleSkinStyles = []; |
| 127 | |
| 128 | /** |
| 129 | * @internal For ServiceWiring only (TODO: Make stable as part of T32956). |
| 130 | * @param Config $config Generic pass-through for use by extension callbacks |
| 131 | * and other MediaWiki-specific module classes. |
| 132 | * @param LoggerInterface|null $logger [optional] |
| 133 | * @param DependencyStore|null $tracker [optional] |
| 134 | * @param array $params [optional] |
| 135 | * - loadScript: URL path to the load.php entrypoint. |
| 136 | * Default: `'/load.php'`. |
| 137 | * - maxageVersioned: HTTP cache max-age in seconds for URLs with a "version" parameter. |
| 138 | * This applies to most load.php responses, and may have a long duration (e.g. weeks or |
| 139 | * months), because a change in the module bundle will naturally produce a different URL |
| 140 | * and thus automatically bust the CDN and web browser caches. |
| 141 | * Default: 30 days. |
| 142 | * - maxageUnversioned: HTTP cache max-age in seconds for URLs without a "version" parameter. |
| 143 | * This should have a short duration (e.g. minutes), and affects the startup manifest which |
| 144 | * controls how quickly changes (in the module registry, dependency tree, or module content) |
| 145 | * will propagate to clients. |
| 146 | * Default: 5 minutes. |
| 147 | */ |
| 148 | public function __construct( |
| 149 | Config $config, |
| 150 | ?LoggerInterface $logger = null, |
| 151 | ?DependencyStore $tracker = null, |
| 152 | array $params = [] |
| 153 | ) { |
| 154 | $this->maxageVersioned = $params['maxageVersioned'] ?? 30 * 24 * 60 * 60; |
| 155 | $this->maxageUnversioned = $params['maxageUnversioned'] ?? 5 * 60; |
| 156 | |
| 157 | $this->config = $config; |
| 158 | $this->logger = $logger ?: new NullLogger(); |
| 159 | |
| 160 | $services = MediaWikiServices::getInstance(); |
| 161 | $this->hookContainer = $services->getHookContainer(); |
| 162 | |
| 163 | $this->srvCache = $services->getLocalServerObjectCache(); |
| 164 | $this->statsFactory = $services->getStatsFactory(); |
| 165 | |
| 166 | // Add 'local' source first |
| 167 | $this->addSource( 'local', $params['loadScript'] ?? '/load.php' ); |
| 168 | |
| 169 | // Special module that always exists |
| 170 | $this->register( 'startup', [ 'class' => StartUpModule::class ] ); |
| 171 | |
| 172 | $this->setMessageBlobStore( |
| 173 | new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() ) |
| 174 | ); |
| 175 | |
| 176 | $tracker = $tracker ?: new DependencyStore( new HashBagOStuff() ); |
| 177 | $this->setDependencyStore( $tracker ); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * @return Config |
| 182 | */ |
| 183 | public function getConfig() { |
| 184 | return $this->config; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * @since 1.26 |
| 189 | * @param LoggerInterface $logger |
| 190 | */ |
| 191 | public function setLogger( LoggerInterface $logger ): void { |
| 192 | $this->logger = $logger; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * @since 1.27 |
| 197 | * @return LoggerInterface |
| 198 | */ |
| 199 | public function getLogger(): LoggerInterface { |
| 200 | return $this->logger; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * @since 1.26 |
| 205 | * @return MessageBlobStore |
| 206 | */ |
| 207 | public function getMessageBlobStore() { |
| 208 | return $this->blobStore; |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * @since 1.25 |
| 213 | * @param MessageBlobStore $blobStore |
| 214 | */ |
| 215 | public function setMessageBlobStore( MessageBlobStore $blobStore ) { |
| 216 | $this->blobStore = $blobStore; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * @since 1.35 |
| 221 | * @param DependencyStore $tracker |
| 222 | */ |
| 223 | public function setDependencyStore( DependencyStore $tracker ) { |
| 224 | $this->depStore = $tracker; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * @internal For use by Module.php |
| 229 | * @since 1.44 |
| 230 | * @return DependencyStore |
| 231 | */ |
| 232 | public function getDependencyStore(): DependencyStore { |
| 233 | return $this->depStore; |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * @internal For use by ServiceWiring.php |
| 238 | * @param array $moduleSkinStyles |
| 239 | */ |
| 240 | public function setModuleSkinStyles( array $moduleSkinStyles ) { |
| 241 | $this->moduleSkinStyles = $moduleSkinStyles; |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Register a module with the ResourceLoader system. |
| 246 | * |
| 247 | * @see $wgResourceModules for the available options. |
| 248 | * @param string|array[] $name Module name as a string or, array of module info arrays |
| 249 | * keyed by name. |
| 250 | * @param array|null $info Module info array. When using the first parameter to register |
| 251 | * multiple modules at once, this parameter is optional. |
| 252 | * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas) |
| 253 | * @throws InvalidArgumentException If the module info is not an array |
| 254 | */ |
| 255 | public function register( $name, ?array $info = null ) { |
| 256 | // Allow multiple modules to be registered in one call |
| 257 | $registrations = is_array( $name ) ? $name : [ $name => $info ]; |
| 258 | foreach ( $registrations as $name => $info ) { |
| 259 | // Warn on duplicate registrations |
| 260 | if ( isset( $this->moduleInfos[$name] ) ) { |
| 261 | // A module has already been registered by this name |
| 262 | $this->logger->warning( |
| 263 | 'ResourceLoader duplicate registration warning. ' . |
| 264 | 'Another module has already been registered as ' . $name |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | // Check validity |
| 269 | if ( !self::isValidModuleName( $name ) ) { |
| 270 | throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, " |
| 271 | . "see ResourceLoader::isValidModuleName()" ); |
| 272 | } |
| 273 | if ( !is_array( $info ) ) { |
| 274 | throw new InvalidArgumentException( |
| 275 | 'Invalid module info for "' . $name . '": expected array, got ' . get_debug_type( $info ) |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | // Attach module |
| 280 | $this->moduleInfos[$name] = $info; |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * @internal For use by ServiceWiring only |
| 286 | * @codeCoverageIgnore |
| 287 | */ |
| 288 | public function registerTestModules(): void { |
| 289 | $extRegistry = ExtensionRegistry::getInstance(); |
| 290 | $testModules = $extRegistry->getAttribute( 'QUnitTestModule' ); |
| 291 | |
| 292 | $testModuleNames = []; |
| 293 | foreach ( $testModules as $name => &$module ) { |
| 294 | // Turn any single-module dependency into an array |
| 295 | if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) { |
| 296 | $module['dependencies'] = [ $module['dependencies'] ]; |
| 297 | } |
| 298 | |
| 299 | // Ensure the testrunner loads before any tests |
| 300 | $module['dependencies'][] = 'mediawiki.qunit-testrunner'; |
| 301 | |
| 302 | // Keep track of the modules to load on SpecialJavaScriptTest |
| 303 | $testModuleNames[] = $name; |
| 304 | } |
| 305 | |
| 306 | // Core test modules (their names have further precedence). |
| 307 | $testModules = ( include MW_INSTALL_PATH . '/tests/qunit/QUnitTestResources.php' ) + $testModules; |
| 308 | $testModuleNames[] = 'test.MediaWiki'; |
| 309 | |
| 310 | $this->register( $testModules ); |
| 311 | $this->testModuleNames = $testModuleNames; |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * Add a foreign source of modules. |
| 316 | * |
| 317 | * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z). |
| 318 | * |
| 319 | * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ] |
| 320 | * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for |
| 321 | * backwards-compatibility. |
| 322 | * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key. |
| 323 | */ |
| 324 | public function addSource( $sources, $loadUrl = null ) { |
| 325 | if ( !is_array( $sources ) ) { |
| 326 | $sources = [ $sources => $loadUrl ]; |
| 327 | } |
| 328 | foreach ( $sources as $id => $source ) { |
| 329 | // Disallow duplicates |
| 330 | if ( isset( $this->sources[$id] ) ) { |
| 331 | throw new RuntimeException( 'Cannot register source ' . $id . ' twice' ); |
| 332 | } |
| 333 | |
| 334 | // Support: MediaWiki 1.24 and earlier |
| 335 | if ( is_array( $source ) ) { |
| 336 | if ( !isset( $source['loadScript'] ) ) { |
| 337 | throw new InvalidArgumentException( 'Each source must have a "loadScript" key' ); |
| 338 | } |
| 339 | $source = $source['loadScript']; |
| 340 | } |
| 341 | |
| 342 | $this->sources[$id] = $source; |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * @return string[] |
| 348 | */ |
| 349 | public function getModuleNames() { |
| 350 | return array_keys( $this->moduleInfos ); |
| 351 | } |
| 352 | |
| 353 | /** |
| 354 | * Get a list of modules with QUnit tests. |
| 355 | * |
| 356 | * @internal For use by SpecialJavaScriptTest only |
| 357 | * @return string[] |
| 358 | * @codeCoverageIgnore |
| 359 | */ |
| 360 | public function getTestSuiteModuleNames() { |
| 361 | return $this->testModuleNames; |
| 362 | } |
| 363 | |
| 364 | /** |
| 365 | * Check whether a ResourceLoader module is registered |
| 366 | * |
| 367 | * @since 1.25 |
| 368 | * @param string $name |
| 369 | * @return bool |
| 370 | */ |
| 371 | public function isModuleRegistered( $name ) { |
| 372 | return isset( $this->moduleInfos[$name] ); |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * Get the Module object for a given module name. |
| 377 | * |
| 378 | * If an array of module parameters exists but a Module object has not yet |
| 379 | * been instantiated, this method will instantiate and cache that object such that |
| 380 | * subsequent calls simply return the same object. |
| 381 | * |
| 382 | * @param string $name Module name |
| 383 | * @return Module|null If module has been registered, return a |
| 384 | * Module instance. Otherwise, return null. |
| 385 | */ |
| 386 | public function getModule( $name ) { |
| 387 | if ( !isset( $this->modules[$name] ) ) { |
| 388 | if ( !isset( $this->moduleInfos[$name] ) ) { |
| 389 | // No such module |
| 390 | return null; |
| 391 | } |
| 392 | // Construct the requested module object |
| 393 | $info = $this->moduleInfos[$name]; |
| 394 | if ( isset( $info['factory'] ) ) { |
| 395 | /** @var Module $object */ |
| 396 | $object = $info['factory']( $info ); |
| 397 | } else { |
| 398 | $class = $info['class'] ?? FileModule::class; |
| 399 | /** @var Module $object */ |
| 400 | $object = new $class( $info ); |
| 401 | } |
| 402 | $object->setConfig( $this->getConfig() ); |
| 403 | $object->setLogger( $this->logger ); |
| 404 | $object->setHookContainer( $this->hookContainer ); |
| 405 | $object->setName( $name ); |
| 406 | $object->setSkinStylesOverride( $this->moduleSkinStyles ); |
| 407 | $this->modules[$name] = $object; |
| 408 | } |
| 409 | |
| 410 | return $this->modules[$name]; |
| 411 | } |
| 412 | |
| 413 | /** |
| 414 | * Load information stored in the database and dependency tracking store about modules |
| 415 | * |
| 416 | * @param string[] $moduleNames |
| 417 | * @param Context $context ResourceLoader-specific context of the request |
| 418 | */ |
| 419 | public function preloadModuleInfo( array $moduleNames, Context $context ) { |
| 420 | // Load all tracked indirect file dependencies for the modules |
| 421 | $vary = Module::getVary( $context ); |
| 422 | $entitiesByModule = []; |
| 423 | foreach ( $moduleNames as $moduleName ) { |
| 424 | $entitiesByModule[$moduleName] = "$moduleName|$vary"; |
| 425 | } |
| 426 | $depsByEntity = $this->depStore->retrieveMulti( |
| 427 | $entitiesByModule |
| 428 | ); |
| 429 | // Inject the indirect file dependencies for all the modules |
| 430 | foreach ( $moduleNames as $moduleName ) { |
| 431 | $module = $this->getModule( $moduleName ); |
| 432 | if ( $module ) { |
| 433 | $entity = $entitiesByModule[$moduleName]; |
| 434 | $deps = $depsByEntity[$entity]; |
| 435 | $paths = $deps['paths']; |
| 436 | $module->setFileDependencies( $context, $paths ); |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | WikiModule::preloadTitleInfo( $context, $moduleNames ); |
| 441 | |
| 442 | // Prime in-object cache for message blobs for modules with messages |
| 443 | $modulesWithMessages = []; |
| 444 | foreach ( $moduleNames as $moduleName ) { |
| 445 | $module = $this->getModule( $moduleName ); |
| 446 | if ( $module && $module->getMessages() ) { |
| 447 | $modulesWithMessages[$moduleName] = $module; |
| 448 | } |
| 449 | } |
| 450 | // Prime in-object cache for message blobs for modules with messages |
| 451 | $lang = $context->getLanguage(); |
| 452 | $store = $this->getMessageBlobStore(); |
| 453 | $blobs = $store->getBlobs( $modulesWithMessages, $lang ); |
| 454 | foreach ( $blobs as $moduleName => $blob ) { |
| 455 | $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang ); |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Get the list of sources. |
| 461 | * |
| 462 | * @return array Like [ id => load.php url, ... ] |
| 463 | */ |
| 464 | public function getSources() { |
| 465 | return $this->sources; |
| 466 | } |
| 467 | |
| 468 | /** |
| 469 | * Get the URL to the load.php endpoint for the given ResourceLoader source. |
| 470 | * |
| 471 | * @since 1.24 |
| 472 | * @param string $source Source ID |
| 473 | * @return string |
| 474 | * @throws UnexpectedValueException If the source ID was not registered |
| 475 | */ |
| 476 | public function getLoadScript( $source ) { |
| 477 | if ( !isset( $this->sources[$source] ) ) { |
| 478 | throw new UnexpectedValueException( "Unknown source '$source'" ); |
| 479 | } |
| 480 | return $this->sources[$source]; |
| 481 | } |
| 482 | |
| 483 | /** |
| 484 | * @internal For use by StartUpModule only. |
| 485 | */ |
| 486 | public const HASH_LENGTH = 5; |
| 487 | |
| 488 | /** |
| 489 | * Create a hash for module versioning purposes. |
| 490 | * |
| 491 | * This hash is used in three ways: |
| 492 | * |
| 493 | * - To differentiate between the current version and a past version |
| 494 | * of a module by the same name. |
| 495 | * |
| 496 | * In the cache key of localStorage in the browser (mw.loader.store). |
| 497 | * This store keeps only one version of any given module. As long as the |
| 498 | * next version the client encounters has a different hash from the last |
| 499 | * version it saw, it will correctly discard it in favour of a network fetch. |
| 500 | * |
| 501 | * A browser may evict a site's storage container for any reason (e.g. when |
| 502 | * the user hasn't visited a site for some time, and/or when the device is |
| 503 | * low on storage space). Anecdotally it seems devices rarely keep unused |
| 504 | * storage beyond 2 weeks on mobile devices and 4 weeks on desktop. |
| 505 | * But, there is no hard limit or expiration on localStorage. |
| 506 | * ResourceLoader's Client also clears localStorage when the user changes |
| 507 | * their language preference or when they (temporarily) use Debug Mode. |
| 508 | * |
| 509 | * The only hard factors that reduce the range of possible versions are |
| 510 | * 1) the name and existence of a given module, and |
| 511 | * 2) the TTL for mw.loader.store, and |
| 512 | * 3) the `$wgResourceLoaderStorageVersion` configuration variable. |
| 513 | * |
| 514 | * - To identify a batch response of modules from load.php in an HTTP cache. |
| 515 | * |
| 516 | * When fetching modules in a batch from load.php, a combined hash |
| 517 | * is created by the JS code, and appended as query parameter. |
| 518 | * |
| 519 | * In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache, |
| 520 | * these urls are used to identify other previously cached responses. |
| 521 | * The range of possible versions a given version has to be unique amongst |
| 522 | * is determined by the maximum duration each response is stored for, which |
| 523 | * is controlled by `$wgResourceLoaderMaxage['versioned']`. |
| 524 | * |
| 525 | * - To detect race conditions between multiple web servers in a MediaWiki |
| 526 | * deployment of which some have the newer version and some still the older |
| 527 | * version. |
| 528 | * |
| 529 | * An HTTP request from a browser for the Startup manifest may be responded |
| 530 | * to by a server with the newer version. The browser may then use that to |
| 531 | * request a given module, which may then be responded to by a server with |
| 532 | * the older version. To avoid caching this for too long (which would pollute |
| 533 | * all other users without repairing itself), the combined hash that the JS |
| 534 | * client adds to the url is verified by the server (in ::sendResponseHeaders). |
| 535 | * If they don't match, we instruct cache proxies and clients to not cache |
| 536 | * this response as long as they normally would. This is also the reason |
| 537 | * that the algorithm used here in PHP must match the one used in JS. |
| 538 | * |
| 539 | * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and |
| 540 | * needs up to 7 chars in base 36. |
| 541 | * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga), |
| 542 | * (but with fnv132 we'd use very little of this range, mostly padding). |
| 543 | * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga). |
| 544 | * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega). |
| 545 | * |
| 546 | * @since 1.26 |
| 547 | * @param string $value |
| 548 | * @return string Hash |
| 549 | */ |
| 550 | public static function makeHash( $value ) { |
| 551 | $hash = hash( 'fnv132', $value ); |
| 552 | // The base_convert will pad it (if too short), |
| 553 | // then substr() will trim it (if too long). |
| 554 | return substr( |
| 555 | \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ), |
| 556 | 0, |
| 557 | self::HASH_LENGTH |
| 558 | ); |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * Add an error to the 'errors' array and log it. |
| 563 | * |
| 564 | * @internal For use by StartUpModule. |
| 565 | * @since 1.29 |
| 566 | * @param Exception $e |
| 567 | * @param string $msg |
| 568 | * @param array $context |
| 569 | */ |
| 570 | public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) { |
| 571 | MWExceptionHandler::logException( $e ); |
| 572 | $this->logger->warning( |
| 573 | $msg, |
| 574 | $context + [ 'exception' => $e ] |
| 575 | ); |
| 576 | $this->errors[] = self::formatExceptionNoComment( $e ); |
| 577 | } |
| 578 | |
| 579 | /** |
| 580 | * Helper method to get and combine versions of multiple modules. |
| 581 | * |
| 582 | * @since 1.26 |
| 583 | * @param Context $context |
| 584 | * @param string[] $moduleNames List of known module names |
| 585 | * @return string Hash |
| 586 | */ |
| 587 | public function getCombinedVersion( Context $context, array $moduleNames ) { |
| 588 | if ( !$moduleNames ) { |
| 589 | return ''; |
| 590 | } |
| 591 | $hashes = []; |
| 592 | foreach ( $moduleNames as $module ) { |
| 593 | try { |
| 594 | $hash = $this->getModule( $module )->getVersionHash( $context ); |
| 595 | } catch ( TimeoutException $e ) { |
| 596 | throw $e; |
| 597 | } catch ( Exception $e ) { |
| 598 | // If modules fail to compute a version, don't fail the request (T152266) |
| 599 | // and still compute versions of other modules. |
| 600 | $this->outputErrorAndLog( $e, |
| 601 | 'Calculating version for "{module}" failed: {exception}', |
| 602 | [ |
| 603 | 'module' => $module, |
| 604 | ] |
| 605 | ); |
| 606 | $hash = ''; |
| 607 | } |
| 608 | $hashes[] = $hash; |
| 609 | } |
| 610 | return self::makeHash( implode( '', $hashes ) ); |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * Get the expected value of the 'version' query parameter. |
| 615 | * |
| 616 | * This is used by respond() to set a short Cache-Control header for requests with |
| 617 | * information newer than the current server has. This avoids pollution of edge caches. |
| 618 | * Typically during deployment. (T117587) |
| 619 | * |
| 620 | * This MUST match return value of `mw.loader#getCombinedVersion()` client-side. |
| 621 | * |
| 622 | * @since 1.28 |
| 623 | * @param Context $context |
| 624 | * @param string[] $modules |
| 625 | * @return string Hash |
| 626 | */ |
| 627 | public function makeVersionQuery( Context $context, array $modules ) { |
| 628 | // As of MediaWiki 1.28, the server and client use the same algorithm for combining |
| 629 | // version hashes. There is no technical reason for this to be same, and for years the |
| 630 | // implementations differed. If getCombinedVersion in PHP (used for StartupModule and |
| 631 | // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version' |
| 632 | // query parameter), then this method must continue to match the JS one. |
| 633 | $filtered = []; |
| 634 | foreach ( $modules as $name ) { |
| 635 | if ( !$this->getModule( $name ) ) { |
| 636 | // If a versioned request contains a missing module, the version is a mismatch |
| 637 | // as the client considered a module (and version) we don't have. |
| 638 | return ''; |
| 639 | } |
| 640 | $filtered[] = $name; |
| 641 | } |
| 642 | return $this->getCombinedVersion( $context, $filtered ); |
| 643 | } |
| 644 | |
| 645 | /** |
| 646 | * Output a response to a load request, including the content-type header. |
| 647 | * |
| 648 | * @param Context $context Context in which a response should be formed |
| 649 | * @param string[] $extraHeaders HTTP response headers to send regardless of |
| 650 | * status (200 OK, or 304 Not Modified) and content type (CSS, JS, Image, SourceMap) |
| 651 | */ |
| 652 | public function respond( Context $context, array $extraHeaders = [] ) { |
| 653 | // Buffer output to catch warnings. Normally we'd use ob_clean() on the |
| 654 | // top-level output buffer to clear warnings, but that breaks when ob_gzhandler |
| 655 | // is used: ob_clean() will clear the GZIP header in that case and it won't come |
| 656 | // back for subsequent output, resulting in invalid GZIP. So we have to wrap |
| 657 | // the whole thing in our own output buffer to be sure the active buffer |
| 658 | // doesn't use ob_gzhandler. |
| 659 | // See https://bugs.php.net/bug.php?id=36514 |
| 660 | ob_start(); |
| 661 | |
| 662 | $this->errors = []; |
| 663 | $this->extraHeaders = $extraHeaders; |
| 664 | $responseTime = $this->measureResponseTime(); |
| 665 | ProfilingContext::singleton()->init( MW_ENTRY_POINT, 'respond' ); |
| 666 | |
| 667 | // Find out which modules are missing and instantiate the others |
| 668 | $modules = []; |
| 669 | $missing = []; |
| 670 | foreach ( $context->getModules() as $name ) { |
| 671 | $module = $this->getModule( $name ); |
| 672 | if ( $module ) { |
| 673 | // Do not allow private modules to be loaded from the web. |
| 674 | // This is a security issue, see T36907. |
| 675 | if ( $module->getGroup() === Module::GROUP_PRIVATE ) { |
| 676 | // Not a serious error, just means something is trying to access it (T101806) |
| 677 | $this->logger->debug( "Request for private module '$name' denied" ); |
| 678 | $this->errors[] = "Cannot build private module \"$name\""; |
| 679 | continue; |
| 680 | } |
| 681 | $modules[$name] = $module; |
| 682 | } else { |
| 683 | $missing[] = $name; |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | try { |
| 688 | // Preload for getCombinedVersion() and for batch makeModuleResponse() |
| 689 | $this->preloadModuleInfo( array_keys( $modules ), $context ); |
| 690 | } catch ( TimeoutException $e ) { |
| 691 | throw $e; |
| 692 | } catch ( Exception $e ) { |
| 693 | $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' ); |
| 694 | } |
| 695 | |
| 696 | // Combine versions to propagate cache invalidation |
| 697 | $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) ); |
| 698 | |
| 699 | // See RFC 2616 § 3.11 Entity Tags |
| 700 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11 |
| 701 | $etag = 'W/"' . $versionHash . '"'; |
| 702 | |
| 703 | // Try the client-side cache first |
| 704 | if ( $this->tryRespondNotModified( $context, $etag ) ) { |
| 705 | return; // output handled (buffers cleared) |
| 706 | } |
| 707 | |
| 708 | if ( $context->isSourceMap() ) { |
| 709 | // In source map mode, a version mismatch should be a 404 |
| 710 | if ( $context->getVersion() !== null && $versionHash !== $context->getVersion() ) { |
| 711 | ob_end_clean(); |
| 712 | $this->sendSourceMapVersionMismatch( $versionHash ); |
| 713 | return; |
| 714 | } |
| 715 | // No source maps for images, only=styles requests, or debug mode |
| 716 | if ( $context->getImage() |
| 717 | || $context->getOnly() === 'styles' |
| 718 | || $context->getDebug() |
| 719 | ) { |
| 720 | ob_end_clean(); |
| 721 | $this->sendSourceMapTypeNotImplemented(); |
| 722 | return; |
| 723 | } |
| 724 | } |
| 725 | // Emit source map header if supported (inverse of the above check) |
| 726 | if ( $this->config->get( MainConfigNames::ResourceLoaderEnableSourceMapLinks ) |
| 727 | && !$context->getImageObj() |
| 728 | && !$context->isSourceMap() |
| 729 | && $context->shouldIncludeScripts() |
| 730 | && !$context->getDebug() |
| 731 | ) { |
| 732 | $this->extraHeaders[] = 'SourceMap: ' . $this->getSourceMapUrl( $context, $versionHash ); |
| 733 | } |
| 734 | |
| 735 | // Generate a response |
| 736 | $response = $this->makeModuleResponse( $context, $modules, $missing ); |
| 737 | |
| 738 | // Capture any PHP warnings from the output buffer and append them to the |
| 739 | // error list if we're in debug mode. |
| 740 | if ( $context->getDebug() ) { |
| 741 | $warnings = ob_get_contents(); |
| 742 | if ( $warnings !== false && $warnings !== '' ) { |
| 743 | $this->errors[] = $warnings; |
| 744 | } |
| 745 | } |
| 746 | |
| 747 | $this->sendResponseHeaders( $context, $etag, (bool)$this->errors ); |
| 748 | |
| 749 | // Remove the output buffer and output the response |
| 750 | ob_end_clean(); |
| 751 | |
| 752 | if ( $context->getImageObj() && $this->errors ) { |
| 753 | // We can't show both the error messages and the response when it's an image. |
| 754 | $response = implode( "\n\n", $this->errors ); |
| 755 | } elseif ( $this->errors ) { |
| 756 | $errorText = implode( "\n\n", $this->errors ); |
| 757 | $errorResponse = self::makeComment( $errorText ); |
| 758 | if ( $context->shouldIncludeScripts() ) { |
| 759 | $errorResponse .= 'if (window.console && console.error) { console.error(' |
| 760 | . $context->encodeJson( $errorText ) |
| 761 | . "); }\n"; |
| 762 | // Append the error info to the response |
| 763 | // We used to prepend it, but that would corrupt the source map |
| 764 | $response .= $errorResponse; |
| 765 | } else { |
| 766 | // For styles we can still prepend |
| 767 | $response = $errorResponse . $response; |
| 768 | } |
| 769 | } |
| 770 | |
| 771 | // @phan-suppress-next-line SecurityCheck-XSS |
| 772 | echo $response; |
| 773 | } |
| 774 | |
| 775 | /** |
| 776 | * Send stats about the time used to build the response |
| 777 | * @return ScopedCallback |
| 778 | */ |
| 779 | protected function measureResponseTime() { |
| 780 | $requestStart = $_SERVER['REQUEST_TIME_FLOAT']; |
| 781 | return new ScopedCallback( function () use ( $requestStart ) { |
| 782 | $statTiming = microtime( true ) - $requestStart; |
| 783 | |
| 784 | $this->statsFactory->getTiming( 'resourceloader_response_time_seconds' ) |
| 785 | ->observe( 1000 * $statTiming ); |
| 786 | } ); |
| 787 | } |
| 788 | |
| 789 | /** |
| 790 | * Send main response headers to the client. |
| 791 | * |
| 792 | * Deals with Content-Type, CORS (for stylesheets), and caching. |
| 793 | * |
| 794 | * @param Context $context |
| 795 | * @param string $etag ETag header value |
| 796 | * @param bool $errors Whether there are errors in the response |
| 797 | */ |
| 798 | protected function sendResponseHeaders( |
| 799 | Context $context, $etag, $errors |
| 800 | ): void { |
| 801 | HeaderCallback::warnIfHeadersSent(); |
| 802 | |
| 803 | if ( $errors ) { |
| 804 | $maxage = self::MAXAGE_RECOVER; |
| 805 | } elseif ( |
| 806 | $context->getVersion() !== null |
| 807 | && $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() ) |
| 808 | ) { |
| 809 | // If we need to self-correct, set a very short cache expiry |
| 810 | // to basically just debounce CDN traffic. This applies to: |
| 811 | // - Internal errors, e.g. due to misconfiguration. |
| 812 | // - Version mismatch, e.g. due to deployment race (T117587, T47877). |
| 813 | $this->logger->debug( 'Client and server registry version out of sync' ); |
| 814 | $maxage = self::MAXAGE_RECOVER; |
| 815 | } elseif ( $context->getVersion() === null ) { |
| 816 | // Resources that can't set a version, should have their updates propagate to |
| 817 | // clients quickly. This applies to shared resources linked from HTML, such as |
| 818 | // the startup module and stylesheets. |
| 819 | $maxage = $this->maxageUnversioned; |
| 820 | } else { |
| 821 | // When a version is set, use a long expiry because changes |
| 822 | // will naturally miss the cache by using a different URL. |
| 823 | $maxage = $this->maxageVersioned; |
| 824 | } |
| 825 | if ( $context->getImageObj() ) { |
| 826 | // Output different headers if we're outputting textual errors. |
| 827 | if ( $errors ) { |
| 828 | header( 'Content-Type: text/plain; charset=utf-8' ); |
| 829 | } else { |
| 830 | $context->getImageObj()->sendResponseHeaders( $context ); |
| 831 | } |
| 832 | } elseif ( $context->isSourceMap() ) { |
| 833 | header( 'Content-Type: application/json' ); |
| 834 | } elseif ( $context->getOnly() === 'styles' ) { |
| 835 | header( 'Content-Type: text/css; charset=utf-8' ); |
| 836 | header( 'Access-Control-Allow-Origin: *' ); |
| 837 | } else { |
| 838 | header( 'Content-Type: text/javascript; charset=utf-8' ); |
| 839 | } |
| 840 | // See RFC 2616 § 14.19 ETag |
| 841 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19 |
| 842 | header( 'ETag: ' . $etag ); |
| 843 | if ( $context->getDebug() ) { |
| 844 | // Do not cache debug responses |
| 845 | header( 'Cache-Control: private, no-cache, must-revalidate' ); |
| 846 | } else { |
| 847 | // T132418: When a resource expires mid-way a browsing session, prefer to renew it in |
| 848 | // the background instead of blocking the next page load (eg. startup module, or CSS). |
| 849 | $staleDirective = ( $maxage > self::MAXAGE_RECOVER |
| 850 | ? ", stale-while-revalidate=" . min( 60, intval( $maxage / 2 ) ) |
| 851 | : '' |
| 852 | ); |
| 853 | header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" . $staleDirective ); |
| 854 | header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) ); |
| 855 | } |
| 856 | |
| 857 | foreach ( $this->extraHeaders as $header ) { |
| 858 | header( $header ); |
| 859 | } |
| 860 | } |
| 861 | |
| 862 | /** |
| 863 | * Respond with HTTP 304 Not Modified if appropriate. |
| 864 | * |
| 865 | * If there's an If-None-Match header, respond with a 304 appropriately |
| 866 | * and clear out the output buffer. If the client cache is too old then do nothing. |
| 867 | * |
| 868 | * @param Context $context |
| 869 | * @param string $etag ETag header value |
| 870 | * @return bool True if HTTP 304 was sent and output handled |
| 871 | */ |
| 872 | protected function tryRespondNotModified( Context $context, $etag ) { |
| 873 | // See RFC 2616 § 14.26 If-None-Match |
| 874 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 |
| 875 | $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ); |
| 876 | // Never send 304s in debug mode |
| 877 | if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) { |
| 878 | // There's another bug in ob_gzhandler (see also the comment at |
| 879 | // the top of this function) that causes it to gzip even empty |
| 880 | // responses, meaning it's impossible to produce a truly empty |
| 881 | // response (because the gzip header is always there). This is |
| 882 | // a problem because 304 responses have to be completely empty |
| 883 | // per the HTTP spec, and Firefox behaves buggily when they're not. |
| 884 | // See also https://bugs.php.net/bug.php?id=51579 |
| 885 | // To work around this, we tear down all output buffering before |
| 886 | // sending the 304. |
| 887 | wfResetOutputBuffers( /* $resetGzipEncoding = */ true ); |
| 888 | |
| 889 | HttpStatus::header( 304 ); |
| 890 | $this->sendResponseHeaders( $context, $etag, false ); |
| 891 | return true; |
| 892 | } |
| 893 | return false; |
| 894 | } |
| 895 | |
| 896 | /** |
| 897 | * Get the URL which will deliver the source map for the current response. |
| 898 | * |
| 899 | * @param Context $context |
| 900 | * @param string $version The combined version hash |
| 901 | * @return string |
| 902 | */ |
| 903 | private function getSourceMapUrl( Context $context, $version ) { |
| 904 | return $this->createLoaderURL( 'local', $context, [ |
| 905 | 'sourcemap' => '1', |
| 906 | 'version' => $version |
| 907 | ] ); |
| 908 | } |
| 909 | |
| 910 | /** |
| 911 | * Send an error page for a source map version mismatch |
| 912 | * |
| 913 | * @param string $currentVersion |
| 914 | */ |
| 915 | private function sendSourceMapVersionMismatch( $currentVersion ) { |
| 916 | HttpStatus::header( 404 ); |
| 917 | header( 'Content-Type: text/plain; charset=utf-8' ); |
| 918 | header( 'X-Content-Type-Options: nosniff' ); |
| 919 | echo "Can't deliver a source map for the requested version " . |
| 920 | "since the version is now '$currentVersion'\n"; |
| 921 | } |
| 922 | |
| 923 | /** |
| 924 | * Send an error page when a source map is requested but there is no |
| 925 | * support for the specified content type |
| 926 | */ |
| 927 | private function sendSourceMapTypeNotImplemented() { |
| 928 | HttpStatus::header( 404 ); |
| 929 | header( 'Content-Type: text/plain; charset=utf-8' ); |
| 930 | header( 'X-Content-Type-Options: nosniff' ); |
| 931 | echo "Can't make a source map for this content type\n"; |
| 932 | } |
| 933 | |
| 934 | /** |
| 935 | * Generate a CSS or JS comment block. |
| 936 | * |
| 937 | * Only use this for public data, not error message details. |
| 938 | * |
| 939 | * @param string $text |
| 940 | * @return string |
| 941 | */ |
| 942 | public static function makeComment( $text ) { |
| 943 | $encText = str_replace( '*/', '* /', $text ); |
| 944 | return "/*\n$encText\n*/\n"; |
| 945 | } |
| 946 | |
| 947 | /** |
| 948 | * Handle exception display. |
| 949 | * |
| 950 | * @since 1.25 |
| 951 | * @param Throwable $e Exception to be shown to the user |
| 952 | * @return string Sanitized text for a CSS/JS comment that can be returned to the user |
| 953 | */ |
| 954 | protected static function formatExceptionNoComment( Throwable $e ) { |
| 955 | if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) { |
| 956 | return MWExceptionHandler::getPublicLogMessage( $e ); |
| 957 | } |
| 958 | |
| 959 | // Like MWExceptionHandler::getLogMessage but without $url and $id. |
| 960 | // - Long load.php URL would push the actual error message off-screen into |
| 961 | // scroll overflow in browser devtools. |
| 962 | // - reqId is redundant with X-Request-Id header, plus usually no need to |
| 963 | // correlate the reqId since the backtrace is already included below. |
| 964 | $type = get_class( $e ); |
| 965 | $message = $e->getMessage(); |
| 966 | |
| 967 | return "$type: $message" . |
| 968 | "\nBacktrace:\n" . |
| 969 | MWExceptionHandler::getRedactedTraceAsString( $e ); |
| 970 | } |
| 971 | |
| 972 | /** |
| 973 | * Generate code for a response. |
| 974 | * |
| 975 | * Calling this method also populates the `errors` and `headers` members, |
| 976 | * later used by respond(). |
| 977 | * |
| 978 | * @param Context $context Context in which to generate a response |
| 979 | * @param Module[] $modules List of module objects keyed by module name |
| 980 | * @param string[] $missing List of requested module names that are unregistered (optional) |
| 981 | * @return string Response data |
| 982 | */ |
| 983 | public function makeModuleResponse( Context $context, |
| 984 | array $modules, array $missing = [] |
| 985 | ) { |
| 986 | if ( $modules === [] && $missing === [] ) { |
| 987 | return <<<MESSAGE |
| 988 | /* This file is the Web entry point for MediaWiki's ResourceLoader: |
| 989 | <https://www.mediawiki.org/wiki/ResourceLoader>. In this request, |
| 990 | no modules were requested. Max made me put this here. */ |
| 991 | MESSAGE; |
| 992 | } |
| 993 | |
| 994 | $image = $context->getImageObj(); |
| 995 | if ( $image ) { |
| 996 | $data = $image->getImageData( $context ); |
| 997 | if ( $data === false ) { |
| 998 | $data = ''; |
| 999 | $this->errors[] = 'Image generation failed'; |
| 1000 | } |
| 1001 | return $data; |
| 1002 | } |
| 1003 | |
| 1004 | $states = []; |
| 1005 | foreach ( $missing as $name ) { |
| 1006 | $states[$name] = 'missing'; |
| 1007 | } |
| 1008 | |
| 1009 | $only = $context->getOnly(); |
| 1010 | $debug = (bool)$context->getDebug(); |
| 1011 | if ( $context->isSourceMap() && count( $modules ) > 1 ) { |
| 1012 | $indexMap = new IndexMap; |
| 1013 | } else { |
| 1014 | $indexMap = null; |
| 1015 | } |
| 1016 | |
| 1017 | $out = ''; |
| 1018 | foreach ( $modules as $name => $module ) { |
| 1019 | try { |
| 1020 | [ $response, $offset ] = $this->getOneModuleResponse( $context, $name, $module ); |
| 1021 | if ( $indexMap ) { |
| 1022 | $indexMap->addEncodedMap( $response, $offset ); |
| 1023 | } else { |
| 1024 | $out .= $response; |
| 1025 | } |
| 1026 | } catch ( TimeoutException $e ) { |
| 1027 | throw $e; |
| 1028 | } catch ( Exception $e ) { |
| 1029 | $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' ); |
| 1030 | |
| 1031 | // Respond to client with error-state instead of module implementation |
| 1032 | $states[$name] = 'error'; |
| 1033 | unset( $modules[$name] ); |
| 1034 | } |
| 1035 | } |
| 1036 | |
| 1037 | // Update module states |
| 1038 | if ( $context->shouldIncludeScripts() && !$context->getRaw() ) { |
| 1039 | if ( $modules && $only === 'scripts' ) { |
| 1040 | // Set the state of modules loaded as only scripts to ready as |
| 1041 | // they don't have an mw.loader.impl wrapper that sets the state |
| 1042 | foreach ( $modules as $name => $module ) { |
| 1043 | $states[$name] = 'ready'; |
| 1044 | } |
| 1045 | } |
| 1046 | |
| 1047 | // Set the state of modules we didn't respond to with mw.loader.impl |
| 1048 | if ( $states && !$context->isSourceMap() ) { |
| 1049 | $stateScript = self::makeLoaderStateScript( $context, $states ); |
| 1050 | if ( !$debug ) { |
| 1051 | $stateScript = self::filter( 'minify-js', $stateScript ); |
| 1052 | } |
| 1053 | // Use a linebreak between module script and state script (T162719) |
| 1054 | $out = self::ensureNewline( $out ) . $stateScript; |
| 1055 | } |
| 1056 | } elseif ( $states ) { |
| 1057 | $this->errors[] = 'Problematic modules: ' |
| 1058 | // Silently ignore invalid UTF-8 injected via 'modules' query |
| 1059 | // Don't issue server-side warnings for client errors. (T331641) |
| 1060 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 1061 | . @$context->encodeJson( $states ); |
| 1062 | } |
| 1063 | |
| 1064 | if ( $indexMap ) { |
| 1065 | return $indexMap->getMap(); |
| 1066 | } |
| 1067 | return $out; |
| 1068 | } |
| 1069 | |
| 1070 | /** |
| 1071 | * Get the response of a single module |
| 1072 | * |
| 1073 | * @param Context $context |
| 1074 | * @param string $name |
| 1075 | * @param Module $module |
| 1076 | * @return array{string,IndexMapOffset|null} |
| 1077 | */ |
| 1078 | private function getOneModuleResponse( Context $context, $name, Module $module ) { |
| 1079 | $only = $context->getOnly(); |
| 1080 | // Important: Do not cache minifications of embedded modules |
| 1081 | // This is especially for the private 'user.options' module, |
| 1082 | // which varies on every pageview and would explode the cache (T84960) |
| 1083 | $shouldCache = !$module->shouldEmbedModule( $context ); |
| 1084 | if ( $only === 'styles' ) { |
| 1085 | $minifier = new IdentityMinifierState; |
| 1086 | $this->addOneModuleResponse( $context, $minifier, $name, $module, $this->extraHeaders ); |
| 1087 | // NOTE: This is not actually "minified". IdentityMinifierState is a no-op wrapper |
| 1088 | // to ease code reuse. The filter() call below performs CSS minification. |
| 1089 | $styles = $minifier->getMinifiedOutput(); |
| 1090 | if ( $context->getDebug() ) { |
| 1091 | return [ $styles, null ]; |
| 1092 | } |
| 1093 | return [ |
| 1094 | self::filter( 'minify-css', $styles, |
| 1095 | [ 'cache' => $shouldCache ] ), |
| 1096 | null |
| 1097 | ]; |
| 1098 | } |
| 1099 | |
| 1100 | $replayMinifier = new ReplayMinifierState; |
| 1101 | $this->addOneModuleResponse( $context, $replayMinifier, $name, $module, $this->extraHeaders ); |
| 1102 | |
| 1103 | $minifier = new IdentityMinifierState; |
| 1104 | $replayMinifier->replayOn( $minifier ); |
| 1105 | $plainContent = $minifier->getMinifiedOutput(); |
| 1106 | if ( $context->getDebug() ) { |
| 1107 | return [ $plainContent, null ]; |
| 1108 | } |
| 1109 | |
| 1110 | $isHit = true; |
| 1111 | $callback = function () use ( $context, $replayMinifier, &$isHit ) { |
| 1112 | $isHit = false; |
| 1113 | if ( $context->isSourceMap() ) { |
| 1114 | $minifier = ( new JavaScriptMapperState ) |
| 1115 | ->outputFile( $this->createLoaderURL( 'local', $context, [ |
| 1116 | 'modules' => self::makePackedModulesString( $context->getModules() ), |
| 1117 | 'only' => $context->getOnly() |
| 1118 | ] ) ); |
| 1119 | } else { |
| 1120 | $minifier = new JavaScriptMinifierState; |
| 1121 | } |
| 1122 | $replayMinifier->replayOn( $minifier ); |
| 1123 | if ( $context->isSourceMap() ) { |
| 1124 | $sourceMap = $minifier->getRawSourceMap(); |
| 1125 | $generated = $minifier->getMinifiedOutput(); |
| 1126 | $offset = IndexMapOffset::newFromText( $generated ); |
| 1127 | return [ $sourceMap, $offset->toArray() ]; |
| 1128 | } else { |
| 1129 | return [ $minifier->getMinifiedOutput(), null ]; |
| 1130 | } |
| 1131 | }; |
| 1132 | |
| 1133 | // The below is based on ResourceLoader::filter. Keep together to ease review/maintenance: |
| 1134 | // * Handle $shouldCache, skip cache and minify directly if set. |
| 1135 | // * Use minify cache, minify on-demand and populate cache as needed. |
| 1136 | // * Emit resourceloader_cache_total stats. |
| 1137 | |
| 1138 | if ( $shouldCache ) { |
| 1139 | [ $response, $offsetArray ] = $this->srvCache->getWithSetCallback( |
| 1140 | $this->srvCache->makeGlobalKey( |
| 1141 | 'resourceloader-mapped', |
| 1142 | self::CACHE_VERSION, |
| 1143 | $name, |
| 1144 | $context->isSourceMap() ? '1' : '0', |
| 1145 | md5( $plainContent ) |
| 1146 | ), |
| 1147 | BagOStuff::TTL_DAY, |
| 1148 | $callback |
| 1149 | ); |
| 1150 | |
| 1151 | $mapType = $context->isSourceMap() ? 'map-js' : 'minify-js'; |
| 1152 | $this->statsFactory->getCounter( 'resourceloader_cache_total' ) |
| 1153 | ->setLabel( 'type', $mapType ) |
| 1154 | ->setLabel( 'status', $isHit ? 'hit' : 'miss' ) |
| 1155 | ->increment(); |
| 1156 | } else { |
| 1157 | [ $response, $offsetArray ] = $callback(); |
| 1158 | } |
| 1159 | $offset = $offsetArray ? IndexMapOffset::newFromArray( $offsetArray ) : null; |
| 1160 | |
| 1161 | return [ $response, $offset ]; |
| 1162 | } |
| 1163 | |
| 1164 | /** |
| 1165 | * Add the response of a single module to the MinifierState |
| 1166 | * |
| 1167 | * @param Context $context |
| 1168 | * @param MinifierState $minifier |
| 1169 | * @param string $name |
| 1170 | * @param Module $module |
| 1171 | * @param array|null &$headers Array of headers. If it is not null, the |
| 1172 | * module's headers will be appended to this array. |
| 1173 | */ |
| 1174 | private function addOneModuleResponse( |
| 1175 | Context $context, MinifierState $minifier, $name, Module $module, &$headers |
| 1176 | ) { |
| 1177 | $only = $context->getOnly(); |
| 1178 | $debug = (bool)$context->getDebug(); |
| 1179 | $content = $module->getModuleContent( $context ); |
| 1180 | $version = $module->getVersionHash( $context ); |
| 1181 | |
| 1182 | if ( $headers !== null && isset( $content['headers'] ) ) { |
| 1183 | $headers = array_merge( $headers, $content['headers'] ); |
| 1184 | } |
| 1185 | |
| 1186 | // Append output |
| 1187 | switch ( $only ) { |
| 1188 | case 'scripts': |
| 1189 | $scripts = $content['scripts']; |
| 1190 | if ( !is_array( $scripts ) ) { |
| 1191 | // Formerly scripts was usually a string, but now it is |
| 1192 | // normalized to an array by buildContent(). |
| 1193 | throw new InvalidArgumentException( 'scripts must be an array' ); |
| 1194 | } |
| 1195 | if ( isset( $scripts['plainScripts'] ) ) { |
| 1196 | // Add plain scripts |
| 1197 | $this->addPlainScripts( $minifier, $name, $scripts['plainScripts'] ); |
| 1198 | } elseif ( isset( $scripts['files'] ) ) { |
| 1199 | // Add implement call if any |
| 1200 | $this->addImplementScript( |
| 1201 | $minifier, |
| 1202 | $name, |
| 1203 | $version, |
| 1204 | $scripts, |
| 1205 | [], |
| 1206 | null, |
| 1207 | [], |
| 1208 | $content['deprecationWarning'] ?? null |
| 1209 | ); |
| 1210 | } |
| 1211 | break; |
| 1212 | case 'styles': |
| 1213 | $styles = $content['styles']; |
| 1214 | // We no longer separate into media, they are all combined now with |
| 1215 | // custom media type groups into @media .. {} sections as part of the css string. |
| 1216 | // Module returns either an empty array or a numerical array with css strings. |
| 1217 | if ( isset( $styles['css'] ) ) { |
| 1218 | $minifier->addOutput( implode( '', $styles['css'] ) ); |
| 1219 | } |
| 1220 | break; |
| 1221 | default: |
| 1222 | $scripts = $content['scripts'] ?? ''; |
| 1223 | if ( ( $name === 'site' || $name === 'user' ) |
| 1224 | && isset( $scripts['plainScripts'] ) |
| 1225 | ) { |
| 1226 | // Legacy scripts that run in the global scope without a closure. |
| 1227 | // mw.loader.impl will use eval if scripts is a string. |
| 1228 | // Minify manually here, because general response minification is |
| 1229 | // not effective due it being a string literal, not a function. |
| 1230 | $scripts = self::concatenatePlainScripts( $scripts['plainScripts'] ); |
| 1231 | if ( !$debug ) { |
| 1232 | $scripts = self::filter( 'minify-js', $scripts ); // T107377 |
| 1233 | } |
| 1234 | } |
| 1235 | $this->addImplementScript( |
| 1236 | $minifier, |
| 1237 | $name, |
| 1238 | $version, |
| 1239 | $scripts, |
| 1240 | $content['styles'] ?? [], |
| 1241 | isset( $content['messagesBlob'] ) ? new HtmlJsCode( $content['messagesBlob'] ) : null, |
| 1242 | $content['templates'] ?? [], |
| 1243 | $content['deprecationWarning'] ?? null |
| 1244 | ); |
| 1245 | break; |
| 1246 | } |
| 1247 | $minifier->ensureNewline(); |
| 1248 | } |
| 1249 | |
| 1250 | /** |
| 1251 | * Ensure the string is either empty or ends in a line break |
| 1252 | * @internal |
| 1253 | * @param string $str |
| 1254 | * @return string |
| 1255 | */ |
| 1256 | public static function ensureNewline( $str ) { |
| 1257 | $end = substr( $str, -1 ); |
| 1258 | if ( $end === '' || $end === "\n" ) { |
| 1259 | return $str; |
| 1260 | } |
| 1261 | return $str . "\n"; |
| 1262 | } |
| 1263 | |
| 1264 | /** |
| 1265 | * Get names of modules that use a certain message. |
| 1266 | * |
| 1267 | * @param string $messageKey |
| 1268 | * @return string[] List of module names |
| 1269 | */ |
| 1270 | public function getModulesByMessage( $messageKey ) { |
| 1271 | $moduleNames = []; |
| 1272 | foreach ( $this->getModuleNames() as $moduleName ) { |
| 1273 | $module = $this->getModule( $moduleName ); |
| 1274 | if ( in_array( $messageKey, $module->getMessages() ) ) { |
| 1275 | $moduleNames[] = $moduleName; |
| 1276 | } |
| 1277 | } |
| 1278 | return $moduleNames; |
| 1279 | } |
| 1280 | |
| 1281 | /** |
| 1282 | * Generate JS code that calls mw.loader.impl with given module properties |
| 1283 | * and add it to the MinifierState. |
| 1284 | * |
| 1285 | * @param MinifierState $minifier The minifier to which output should be appended |
| 1286 | * @param string $moduleName The module name |
| 1287 | * @param string $version The module version hash |
| 1288 | * @param array|string|string[] $scripts |
| 1289 | * - array: Package files array containing strings for individual JS files, |
| 1290 | * as produced by Module::getScript(). |
| 1291 | * - string: Script contents to eval in global scope (for site/user scripts). |
| 1292 | * - string[]: List of URLs (for debug mode). |
| 1293 | * @param array<string,string|array<string,string[]>> $styles |
| 1294 | * Under optional key "css", there is a concatenated CSS string. |
| 1295 | * Under optional key "url", there is an array by media type withs URLs to stylesheets (for debug mode). |
| 1296 | * These come from Module::getStyles(), formatted by Module:buildContent(). |
| 1297 | * @param HtmlJsCode|null $messages An already JSON-encoded map from message keys to values, |
| 1298 | * wrapped in an HtmlJsCode object. |
| 1299 | * @param array<string,string> $templates Map from template name to template source. |
| 1300 | * @param string|null $deprecationWarning |
| 1301 | */ |
| 1302 | private function addImplementScript( MinifierState $minifier, |
| 1303 | $moduleName, $version, $scripts, $styles, $messages, $templates, $deprecationWarning |
| 1304 | ) { |
| 1305 | $implementKey = "$moduleName@$version"; |
| 1306 | // Plain functions are used instead of arrow functions to avoid |
| 1307 | // defeating lazy compilation on Chrome. (T343407) |
| 1308 | $minifier->addOutput( "mw.loader.impl(function(){return[" . |
| 1309 | Html::encodeJsVar( $implementKey ) . "," ); |
| 1310 | |
| 1311 | // Scripts |
| 1312 | if ( is_string( $scripts ) ) { |
| 1313 | // user/site script |
| 1314 | $minifier->addOutput( Html::encodeJsVar( $scripts ) ); |
| 1315 | } elseif ( is_array( $scripts ) ) { |
| 1316 | if ( isset( $scripts['files'] ) ) { |
| 1317 | $minifier->addOutput( |
| 1318 | "{\"main\":" . |
| 1319 | Html::encodeJsVar( $scripts['main'] ) . |
| 1320 | ",\"files\":" ); |
| 1321 | $this->addFiles( $minifier, $moduleName, $scripts['files'] ); |
| 1322 | $minifier->addOutput( "}" ); |
| 1323 | } elseif ( isset( $scripts['plainScripts'] ) ) { |
| 1324 | if ( $this->isEmptyFileInfos( $scripts['plainScripts'] ) ) { |
| 1325 | $minifier->addOutput( 'null' ); |
| 1326 | } else { |
| 1327 | $minifier->addOutput( "function($,jQuery,require,module){" ); |
| 1328 | $this->addPlainScripts( $minifier, $moduleName, $scripts['plainScripts'] ); |
| 1329 | $minifier->addOutput( "}" ); |
| 1330 | } |
| 1331 | } elseif ( $scripts === [] || isset( $scripts[0] ) ) { |
| 1332 | // Array of URLs |
| 1333 | $minifier->addOutput( Html::encodeJsVar( $scripts ) ); |
| 1334 | } else { |
| 1335 | throw new InvalidArgumentException( 'Invalid script array: ' . |
| 1336 | 'must contain files, plainScripts or be an array of URLs' ); |
| 1337 | } |
| 1338 | } else { |
| 1339 | throw new InvalidArgumentException( 'Script must be a string or array' ); |
| 1340 | } |
| 1341 | |
| 1342 | // mw.loader.impl requires 'styles', 'messages' and 'templates' to be objects (not |
| 1343 | // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead |
| 1344 | // of "{}". Force them to objects. |
| 1345 | $extraArgs = [ |
| 1346 | (object)$styles, |
| 1347 | $messages ?? (object)[], |
| 1348 | (object)$templates, |
| 1349 | $deprecationWarning |
| 1350 | ]; |
| 1351 | self::trimArray( $extraArgs ); |
| 1352 | foreach ( $extraArgs as $arg ) { |
| 1353 | $minifier->addOutput( ',' . Html::encodeJsVar( $arg ) ); |
| 1354 | } |
| 1355 | $minifier->addOutput( "];});" ); |
| 1356 | } |
| 1357 | |
| 1358 | /** |
| 1359 | * Extract the contents of an array of package files, and convert it to a |
| 1360 | * JavaScript array. Add the array to the minifier state. |
| 1361 | * |
| 1362 | * Package files can contain JSON data. |
| 1363 | * |
| 1364 | * @param MinifierState $minifier |
| 1365 | * @param string $moduleName |
| 1366 | * @param array $files |
| 1367 | */ |
| 1368 | private function addFiles( MinifierState $minifier, $moduleName, $files ) { |
| 1369 | $first = true; |
| 1370 | $minifier->addOutput( "{" ); |
| 1371 | foreach ( $files as $fileName => $file ) { |
| 1372 | if ( $first ) { |
| 1373 | $first = false; |
| 1374 | } else { |
| 1375 | $minifier->addOutput( "," ); |
| 1376 | } |
| 1377 | $minifier->addOutput( Html::encodeJsVar( $fileName ) . ':' ); |
| 1378 | $this->addFileContent( $minifier, $moduleName, 'packageFile', $fileName, $file ); |
| 1379 | } |
| 1380 | $minifier->addOutput( "}" ); |
| 1381 | } |
| 1382 | |
| 1383 | /** |
| 1384 | * Add a package file to a MinifierState |
| 1385 | * |
| 1386 | * @param MinifierState $minifier |
| 1387 | * @param string $moduleName |
| 1388 | * @param string $sourceType |
| 1389 | * @param string|int $sourceIndex |
| 1390 | * @param array $file The expanded file info array |
| 1391 | */ |
| 1392 | private function addFileContent( MinifierState $minifier, |
| 1393 | $moduleName, $sourceType, $sourceIndex, array $file |
| 1394 | ) { |
| 1395 | $isScript = ( $file['type'] ?? 'script' ) === 'script'; |
| 1396 | /** @var FilePath|null $filePath */ |
| 1397 | $filePath = $file['filePath'] ?? $file['virtualFilePath'] ?? null; |
| 1398 | if ( $filePath !== null && $filePath->getRemoteBasePath() !== null ) { |
| 1399 | $url = $filePath->getRemotePath(); |
| 1400 | } else { |
| 1401 | $ext = $isScript ? 'js' : 'json'; |
| 1402 | $scriptPath = $this->config->has( MainConfigNames::ScriptPath ) |
| 1403 | ? $this->config->get( MainConfigNames::ScriptPath ) : ''; |
| 1404 | $url = "$scriptPath/virtual-resource/$moduleName-$sourceType-$sourceIndex.$ext"; |
| 1405 | } |
| 1406 | $content = $file['content']; |
| 1407 | if ( $isScript ) { |
| 1408 | if ( $sourceType === 'packageFile' ) { |
| 1409 | // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511). |
| 1410 | // $/jQuery are simply used as globals instead. |
| 1411 | // TODO: Remove $/jQuery param from traditional module closure too (and bump caching) |
| 1412 | $minifier->addOutput( "function(require,module,exports){" ); |
| 1413 | $minifier->addSourceFile( $url, $content, true ); |
| 1414 | $minifier->ensureNewline(); |
| 1415 | $minifier->addOutput( "}" ); |
| 1416 | } else { |
| 1417 | $minifier->addSourceFile( $url, $content, true ); |
| 1418 | $minifier->ensureNewline(); |
| 1419 | } |
| 1420 | } else { |
| 1421 | $content = Html::encodeJsVar( $content, true ); |
| 1422 | $minifier->addSourceFile( $url, $content, true ); |
| 1423 | } |
| 1424 | } |
| 1425 | |
| 1426 | /** |
| 1427 | * Combine a plainScripts array like [ [ 'content' => '...' ] ] into a |
| 1428 | * single string. |
| 1429 | * |
| 1430 | * @param array[] $plainScripts |
| 1431 | * @return string |
| 1432 | */ |
| 1433 | private static function concatenatePlainScripts( $plainScripts ) { |
| 1434 | $s = ''; |
| 1435 | foreach ( $plainScripts as $script ) { |
| 1436 | // Make the script safe to concatenate by making sure there is at least one |
| 1437 | // trailing new line at the end of the content (T29054, T162719) |
| 1438 | $s .= self::ensureNewline( $script['content'] ); |
| 1439 | } |
| 1440 | return $s; |
| 1441 | } |
| 1442 | |
| 1443 | /** |
| 1444 | * Add contents from a plainScripts array like [ [ 'content' => '...' ] |
| 1445 | * to a MinifierState |
| 1446 | * |
| 1447 | * @param MinifierState $minifier |
| 1448 | * @param string $moduleName |
| 1449 | * @param array[] $plainScripts |
| 1450 | */ |
| 1451 | private function addPlainScripts( MinifierState $minifier, $moduleName, $plainScripts ) { |
| 1452 | foreach ( $plainScripts as $index => $file ) { |
| 1453 | $this->addFileContent( $minifier, $moduleName, 'script', $index, $file ); |
| 1454 | } |
| 1455 | } |
| 1456 | |
| 1457 | /** |
| 1458 | * Determine whether an array of file info arrays has empty content |
| 1459 | * |
| 1460 | * @param array $infos |
| 1461 | * @return bool |
| 1462 | */ |
| 1463 | private function isEmptyFileInfos( $infos ) { |
| 1464 | $len = 0; |
| 1465 | foreach ( $infos as $info ) { |
| 1466 | $len += strlen( $info['content'] ?? '' ); |
| 1467 | } |
| 1468 | return $len === 0; |
| 1469 | } |
| 1470 | |
| 1471 | /** |
| 1472 | * Combines an associative array mapping media type to CSS into a |
| 1473 | * single stylesheet with "@media" blocks. |
| 1474 | * |
| 1475 | * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s) |
| 1476 | * @return string[] CSS strings |
| 1477 | */ |
| 1478 | public static function makeCombinedStyles( array $stylePairs ) { |
| 1479 | $out = []; |
| 1480 | foreach ( $stylePairs as $media => $styles ) { |
| 1481 | // FileModule::getStyle can return the styles as a string or an |
| 1482 | // array of strings. This is to allow separation in the front-end. |
| 1483 | $styles = (array)$styles; |
| 1484 | foreach ( $styles as $style ) { |
| 1485 | $style = trim( $style ); |
| 1486 | // Don't output an empty "@media print { }" block (T42498) |
| 1487 | if ( $style === '' ) { |
| 1488 | continue; |
| 1489 | } |
| 1490 | // Transform the media type based on request params and config |
| 1491 | // The way that this relies on $wgRequest to propagate request params is slightly evil |
| 1492 | $media = OutputPage::transformCssMedia( $media ); |
| 1493 | |
| 1494 | if ( $media === '' || $media == 'all' ) { |
| 1495 | $out[] = $style; |
| 1496 | } elseif ( is_string( $media ) ) { |
| 1497 | $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}"; |
| 1498 | } |
| 1499 | // else: skip |
| 1500 | } |
| 1501 | } |
| 1502 | return $out; |
| 1503 | } |
| 1504 | |
| 1505 | /** |
| 1506 | * Wrapper around json_encode that avoids needless escapes, |
| 1507 | * and pretty-prints in debug mode. |
| 1508 | * |
| 1509 | * @param mixed $data |
| 1510 | * @return string|false JSON string, false on error |
| 1511 | */ |
| 1512 | private static function encodeJsonForScript( $data ) { |
| 1513 | // Keep output as small as possible by disabling needless escape modes |
| 1514 | // that PHP uses by default. |
| 1515 | // However, while most module scripts are only served on HTTP responses |
| 1516 | // for JavaScript, some modules can also be embedded in the HTML as inline |
| 1517 | // scripts. This, and the fact that we sometimes need to export strings |
| 1518 | // containing user-generated content and labels that may genuinely contain |
| 1519 | // a sequences like "</script>", we need to encode either '/' or '<'. |
| 1520 | // By default PHP escapes '/'. Let's escape '<' instead which is less common |
| 1521 | // and allows URLs to mostly remain readable. |
| 1522 | $jsonFlags = JSON_UNESCAPED_SLASHES | |
| 1523 | JSON_UNESCAPED_UNICODE | |
| 1524 | JSON_HEX_TAG | |
| 1525 | JSON_HEX_AMP; |
| 1526 | if ( self::inDebugMode() ) { |
| 1527 | $jsonFlags |= JSON_PRETTY_PRINT; |
| 1528 | } |
| 1529 | return json_encode( $data, $jsonFlags ); |
| 1530 | } |
| 1531 | |
| 1532 | /** |
| 1533 | * Format a JS call to mw.loader.state() |
| 1534 | * |
| 1535 | * @internal For use by StartUpModule |
| 1536 | * @param Context $context |
| 1537 | * @param array<string,string> $states |
| 1538 | * @return string JavaScript code |
| 1539 | */ |
| 1540 | public static function makeLoaderStateScript( |
| 1541 | Context $context, array $states |
| 1542 | ) { |
| 1543 | return 'mw.loader.state(' |
| 1544 | // Silently ignore invalid UTF-8 injected via 'modules' query |
| 1545 | // Don't issue server-side warnings for client errors. (T331641) |
| 1546 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 1547 | . @$context->encodeJson( $states ) |
| 1548 | . ');'; |
| 1549 | } |
| 1550 | |
| 1551 | private static function isEmptyObject( stdClass $obj ): bool { |
| 1552 | foreach ( $obj as $value ) { |
| 1553 | return false; |
| 1554 | } |
| 1555 | return true; |
| 1556 | } |
| 1557 | |
| 1558 | /** |
| 1559 | * Remove empty values from the end of an array. |
| 1560 | * |
| 1561 | * Values considered empty: |
| 1562 | * |
| 1563 | * - null |
| 1564 | * - [] |
| 1565 | * - new HtmlJsCode( '{}' ) |
| 1566 | * - new stdClass() |
| 1567 | * - (object)[] |
| 1568 | */ |
| 1569 | private static function trimArray( array &$array ): void { |
| 1570 | $i = count( $array ); |
| 1571 | while ( $i-- ) { |
| 1572 | if ( $array[$i] === null |
| 1573 | || $array[$i] === [] |
| 1574 | || ( $array[$i] instanceof HtmlJsCode && $array[$i]->value === '{}' ) |
| 1575 | || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) ) |
| 1576 | ) { |
| 1577 | unset( $array[$i] ); |
| 1578 | } else { |
| 1579 | break; |
| 1580 | } |
| 1581 | } |
| 1582 | } |
| 1583 | |
| 1584 | /** |
| 1585 | * Format JS code which calls `mw.loader.register()` with the given parameters. |
| 1586 | * |
| 1587 | * @par Example |
| 1588 | * @code |
| 1589 | * |
| 1590 | * ResourceLoader::makeLoaderRegisterScript( $context, [ |
| 1591 | * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ], |
| 1592 | * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ], |
| 1593 | * ... |
| 1594 | * ] ): |
| 1595 | * @endcode |
| 1596 | * |
| 1597 | * @internal For use by StartUpModule only |
| 1598 | * @param Context $context |
| 1599 | * @param array[] $modules Array of module registration arrays, each containing |
| 1600 | * - string: module name |
| 1601 | * - string: module version |
| 1602 | * - array|null: List of dependencies (optional) |
| 1603 | * - int|null: Module group (optional) |
| 1604 | * - string|null: Name of foreign module source, or 'local' (optional) |
| 1605 | * - string|null: Script body of a skip function (optional) |
| 1606 | * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?int,4?:?string,5?:?string}> $modules |
| 1607 | * @return string JavaScript code |
| 1608 | */ |
| 1609 | public static function makeLoaderRegisterScript( |
| 1610 | Context $context, array $modules |
| 1611 | ) { |
| 1612 | // Optimisation: Transform dependency names into indexes when possible |
| 1613 | // to produce smaller output. They are expanded by mw.loader.register on |
| 1614 | // the other end. |
| 1615 | $index = []; |
| 1616 | foreach ( $modules as $i => $module ) { |
| 1617 | // Build module name index |
| 1618 | $index[$module[0]] = $i; |
| 1619 | } |
| 1620 | foreach ( $modules as &$module ) { |
| 1621 | if ( isset( $module[2] ) ) { |
| 1622 | foreach ( $module[2] as &$dependency ) { |
| 1623 | if ( isset( $index[$dependency] ) ) { |
| 1624 | // Replace module name in dependency list with index |
| 1625 | $dependency = $index[$dependency]; |
| 1626 | } |
| 1627 | } |
| 1628 | } |
| 1629 | self::trimArray( $module ); |
| 1630 | } |
| 1631 | |
| 1632 | return 'mw.loader.register(' |
| 1633 | . $context->encodeJson( $modules ) |
| 1634 | . ');'; |
| 1635 | } |
| 1636 | |
| 1637 | /** |
| 1638 | * Format JS code which calls `mw.loader.addSource()` with the given parameters. |
| 1639 | * |
| 1640 | * - ResourceLoader::makeLoaderSourcesScript( $context, |
| 1641 | * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] |
| 1642 | * ); |
| 1643 | * Register sources with the given IDs and properties. |
| 1644 | * |
| 1645 | * @internal For use by StartUpModule only |
| 1646 | * @param Context $context |
| 1647 | * @param array<string,string> $sources |
| 1648 | * @return string JavaScript code |
| 1649 | */ |
| 1650 | public static function makeLoaderSourcesScript( |
| 1651 | Context $context, array $sources |
| 1652 | ) { |
| 1653 | return 'mw.loader.addSource(' |
| 1654 | . $context->encodeJson( $sources ) |
| 1655 | . ');'; |
| 1656 | } |
| 1657 | |
| 1658 | /** |
| 1659 | * Wrap JavaScript code to run after the startup module. |
| 1660 | * |
| 1661 | * @param string $script JavaScript code |
| 1662 | * @return string JavaScript code |
| 1663 | */ |
| 1664 | public static function makeLoaderConditionalScript( $script ) { |
| 1665 | // Adds a function to lazy-created RLQ |
| 1666 | return '(RLQ=window.RLQ||[]).push(function(){' . |
| 1667 | trim( $script ) . '});'; |
| 1668 | } |
| 1669 | |
| 1670 | /** |
| 1671 | * Wrap JavaScript code to run after a required module. |
| 1672 | * |
| 1673 | * @since 1.32 |
| 1674 | * @param string|string[] $modules Module name(s) |
| 1675 | * @param string $script JavaScript code |
| 1676 | * @return string JavaScript code |
| 1677 | */ |
| 1678 | public static function makeInlineCodeWithModule( $modules, $script ) { |
| 1679 | // Adds an array to lazy-created RLQ |
| 1680 | return '(RLQ=window.RLQ||[]).push([' |
| 1681 | . json_encode( $modules ) . ',' |
| 1682 | . 'function(){' . trim( $script ) . '}' |
| 1683 | . ']);'; |
| 1684 | } |
| 1685 | |
| 1686 | /** |
| 1687 | * Make an HTML script that runs given JS code after startup and base modules. |
| 1688 | * |
| 1689 | * The code will be wrapped in a closure, and it will be executed by ResourceLoader's |
| 1690 | * startup module if the client has adequate support for MediaWiki JavaScript code. |
| 1691 | * |
| 1692 | * @param string $script JavaScript code |
| 1693 | * @param string|null $nonce Unused |
| 1694 | * @return string|WrappedString HTML |
| 1695 | */ |
| 1696 | public static function makeInlineScript( $script, $nonce = null ) { |
| 1697 | $js = self::makeLoaderConditionalScript( $script ); |
| 1698 | return new WrappedString( |
| 1699 | Html::inlineScript( $js ), |
| 1700 | "<script>(RLQ=window.RLQ||[]).push(function(){", |
| 1701 | '});</script>' |
| 1702 | ); |
| 1703 | } |
| 1704 | |
| 1705 | /** |
| 1706 | * Return JS code which will set the MediaWiki configuration array to |
| 1707 | * the given value. |
| 1708 | * |
| 1709 | * @param array $configuration List of configuration values keyed by variable name |
| 1710 | * @return string JavaScript code |
| 1711 | * @throws LogicException |
| 1712 | * |
| 1713 | * @deprecated since 1.44, Consider using package files instead or |
| 1714 | * you can return mw.config.set() combined with RL\Context::encodeJson, if available. |
| 1715 | * If not, use FormatJson::encode. |
| 1716 | */ |
| 1717 | public static function makeConfigSetScript( array $configuration ) { |
| 1718 | $json = self::encodeJsonForScript( $configuration ); |
| 1719 | if ( $json === false ) { |
| 1720 | $e = new LogicException( |
| 1721 | 'JSON serialization of config data failed. ' . |
| 1722 | 'This usually means the config data is not valid UTF-8.' |
| 1723 | ); |
| 1724 | MWExceptionHandler::logException( $e ); |
| 1725 | return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');'; |
| 1726 | } |
| 1727 | return "mw.config.set($json);"; |
| 1728 | } |
| 1729 | |
| 1730 | /** |
| 1731 | * Convert an array of module names to a packed query string. |
| 1732 | * |
| 1733 | * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]` |
| 1734 | * becomes `'foo.bar,baz|bar.baz,quux'`. |
| 1735 | * |
| 1736 | * This process is reversed by ResourceLoader::expandModuleNames(). |
| 1737 | * See also mw.loader#buildModulesString() which is a port of this, used |
| 1738 | * on the client-side. |
| 1739 | * |
| 1740 | * @param string[] $modules List of module names (strings) |
| 1741 | * @return string Packed query string |
| 1742 | */ |
| 1743 | public static function makePackedModulesString( array $modules ) { |
| 1744 | $moduleMap = []; // [ prefix => [ suffixes ] ] |
| 1745 | foreach ( $modules as $module ) { |
| 1746 | $pos = strrpos( $module, '.' ); |
| 1747 | $prefix = $pos === false ? '' : substr( $module, 0, $pos ); |
| 1748 | $suffix = $pos === false ? $module : substr( $module, $pos + 1 ); |
| 1749 | $moduleMap[$prefix][] = $suffix; |
| 1750 | } |
| 1751 | |
| 1752 | $arr = []; |
| 1753 | foreach ( $moduleMap as $prefix => $suffixes ) { |
| 1754 | $p = $prefix === '' ? '' : $prefix . '.'; |
| 1755 | $arr[] = $p . implode( ',', $suffixes ); |
| 1756 | } |
| 1757 | return implode( '|', $arr ); |
| 1758 | } |
| 1759 | |
| 1760 | /** |
| 1761 | * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to |
| 1762 | * an array of module names like `[ 'jquery.foo', 'jquery.bar', |
| 1763 | * 'jquery.ui.baz', 'jquery.ui.quux' ]`. |
| 1764 | * |
| 1765 | * This process is reversed by ResourceLoader::makePackedModulesString(). |
| 1766 | * |
| 1767 | * @since 1.33 |
| 1768 | * @param string $modules Packed module name list |
| 1769 | * @return string[] Array of module names |
| 1770 | */ |
| 1771 | public static function expandModuleNames( $modules ) { |
| 1772 | $retval = []; |
| 1773 | $exploded = explode( '|', $modules ); |
| 1774 | foreach ( $exploded as $group ) { |
| 1775 | if ( !str_contains( $group, ',' ) ) { |
| 1776 | // This is not a set of modules in foo.bar,baz notation |
| 1777 | // but a single module |
| 1778 | $retval[] = $group; |
| 1779 | continue; |
| 1780 | } |
| 1781 | // This is a set of modules in foo.bar,baz notation |
| 1782 | $pos = strrpos( $group, '.' ); |
| 1783 | if ( $pos === false ) { |
| 1784 | // Prefixless modules, i.e. without dots |
| 1785 | $retval = array_merge( $retval, explode( ',', $group ) ); |
| 1786 | continue; |
| 1787 | } |
| 1788 | // We have a prefix and a bunch of suffixes |
| 1789 | $prefix = substr( $group, 0, $pos ); // 'foo' |
| 1790 | $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ] |
| 1791 | foreach ( $suffixes as $suffix ) { |
| 1792 | $retval[] = "$prefix.$suffix"; |
| 1793 | } |
| 1794 | } |
| 1795 | return $retval; |
| 1796 | } |
| 1797 | |
| 1798 | /** |
| 1799 | * Determine whether debug mode is on. |
| 1800 | * |
| 1801 | * Order of priority is: |
| 1802 | * - 1) Request parameter, |
| 1803 | * - 2) Cookie, |
| 1804 | * - 3) Site configuration. |
| 1805 | * |
| 1806 | * @return int |
| 1807 | */ |
| 1808 | public static function inDebugMode() { |
| 1809 | if ( self::$debugMode === null ) { |
| 1810 | global $wgRequest; |
| 1811 | |
| 1812 | $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get( |
| 1813 | MainConfigNames::ResourceLoaderDebug ); |
| 1814 | $str = $wgRequest->getRawVal( 'debug' ) ?? |
| 1815 | $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' ); |
| 1816 | self::$debugMode = Context::debugFromString( $str ); |
| 1817 | } |
| 1818 | return self::$debugMode; |
| 1819 | } |
| 1820 | |
| 1821 | /** |
| 1822 | * Reset static members used for caching. |
| 1823 | * |
| 1824 | * Global state and $wgRequest are evil, but we're using it right |
| 1825 | * now and sometimes we need to be able to force ResourceLoader to |
| 1826 | * re-evaluate the context because it has changed (e.g. in the test suite). |
| 1827 | * |
| 1828 | * @internal For use by unit tests |
| 1829 | * @codeCoverageIgnore |
| 1830 | */ |
| 1831 | public static function clearCache() { |
| 1832 | self::$debugMode = null; |
| 1833 | } |
| 1834 | |
| 1835 | /** |
| 1836 | * Build a load.php URL |
| 1837 | * |
| 1838 | * @since 1.24 |
| 1839 | * @param string $source Name of the ResourceLoader source |
| 1840 | * @param Context $context |
| 1841 | * @param array $extraQuery |
| 1842 | * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too. |
| 1843 | */ |
| 1844 | public function createLoaderURL( $source, Context $context, |
| 1845 | array $extraQuery = [] |
| 1846 | ) { |
| 1847 | $query = self::createLoaderQuery( $context, $extraQuery ); |
| 1848 | $script = $this->getLoadScript( $source ); |
| 1849 | |
| 1850 | return wfAppendQuery( $script, $query ); |
| 1851 | } |
| 1852 | |
| 1853 | /** |
| 1854 | * Helper for createLoaderURL() |
| 1855 | * |
| 1856 | * @since 1.24 |
| 1857 | * @see makeLoaderQuery |
| 1858 | * @param Context $context |
| 1859 | * @param array $extraQuery |
| 1860 | * @return array |
| 1861 | */ |
| 1862 | protected static function createLoaderQuery( |
| 1863 | Context $context, array $extraQuery = [] |
| 1864 | ) { |
| 1865 | return self::makeLoaderQuery( |
| 1866 | $context->getModules(), |
| 1867 | $context->getLanguage(), |
| 1868 | $context->getSkin(), |
| 1869 | $context->getUser(), |
| 1870 | $context->getVersion(), |
| 1871 | $context->getDebug(), |
| 1872 | $context->getOnly(), |
| 1873 | $context->getRequest()->getBool( 'printable' ), |
| 1874 | null, |
| 1875 | $extraQuery |
| 1876 | ); |
| 1877 | } |
| 1878 | |
| 1879 | /** |
| 1880 | * Build a query array (array representation of query string) for load.php. Helper |
| 1881 | * function for createLoaderURL(). |
| 1882 | * |
| 1883 | * @param string[] $modules |
| 1884 | * @param string $lang |
| 1885 | * @param string $skin |
| 1886 | * @param string|null $user |
| 1887 | * @param string|null $version |
| 1888 | * @param int $debug |
| 1889 | * @param string|null $only |
| 1890 | * @param bool $printable |
| 1891 | * @param bool|null $handheld Unused as of MW 1.38 |
| 1892 | * @param array $extraQuery |
| 1893 | * @return array |
| 1894 | */ |
| 1895 | public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null, |
| 1896 | $version = null, $debug = Context::DEBUG_OFF, $only = null, |
| 1897 | $printable = false, $handheld = null, array $extraQuery = [] |
| 1898 | ) { |
| 1899 | $query = [ |
| 1900 | 'modules' => self::makePackedModulesString( $modules ), |
| 1901 | ]; |
| 1902 | // Keep urls short by omitting query parameters that |
| 1903 | // match the defaults assumed by Context. |
| 1904 | // Note: This relies on the defaults either being insignificant or forever constant, |
| 1905 | // as otherwise cached urls could change in meaning when the defaults change. |
| 1906 | if ( $lang !== Context::DEFAULT_LANG ) { |
| 1907 | $query['lang'] = $lang; |
| 1908 | } |
| 1909 | if ( $skin !== Context::DEFAULT_SKIN ) { |
| 1910 | $query['skin'] = $skin; |
| 1911 | } |
| 1912 | if ( $debug !== Context::DEBUG_OFF ) { |
| 1913 | $query['debug'] = strval( $debug ); |
| 1914 | } |
| 1915 | if ( $user !== null ) { |
| 1916 | $query['user'] = $user; |
| 1917 | } |
| 1918 | if ( $version !== null ) { |
| 1919 | $query['version'] = $version; |
| 1920 | } |
| 1921 | if ( $only !== null ) { |
| 1922 | $query['only'] = $only; |
| 1923 | } |
| 1924 | if ( $printable ) { |
| 1925 | $query['printable'] = 1; |
| 1926 | } |
| 1927 | foreach ( $extraQuery as $name => $value ) { |
| 1928 | $query[$name] = $value; |
| 1929 | } |
| 1930 | |
| 1931 | // Make queries uniform in order |
| 1932 | ksort( $query ); |
| 1933 | return $query; |
| 1934 | } |
| 1935 | |
| 1936 | /** |
| 1937 | * Check a module name for validity. |
| 1938 | * |
| 1939 | * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be |
| 1940 | * at most 255 bytes. |
| 1941 | * |
| 1942 | * @param string $moduleName Module name to check |
| 1943 | * @return bool Whether $moduleName is a valid module name |
| 1944 | */ |
| 1945 | public static function isValidModuleName( $moduleName ) { |
| 1946 | $len = strlen( $moduleName ); |
| 1947 | return ( $len <= 255 |
| 1948 | && strcspn( $moduleName, '!,|', 0, $len ) === $len ) |
| 1949 | && ( !str_starts_with( $moduleName, "./" ) && !str_starts_with( $moduleName, "../" ) ); |
| 1950 | } |
| 1951 | |
| 1952 | /** |
| 1953 | * Return a LESS compiler that is set up for use with MediaWiki. |
| 1954 | * |
| 1955 | * @since 1.27 |
| 1956 | * @param array $vars Associative array of variables that should be used |
| 1957 | * for compilation. Since 1.32, this method no longer automatically includes |
| 1958 | * global LESS vars from ResourceLoader::getLessVars (T191937). |
| 1959 | * @param array $importDirs Additional directories to look in for @import (since 1.36) |
| 1960 | * @return Less_Parser |
| 1961 | */ |
| 1962 | public function getLessCompiler( array $vars = [], array $importDirs = [] ) { |
| 1963 | // When called from the installer, it is possible that a required PHP extension |
| 1964 | // is missing (at least for now; see T49564). If this is the case, throw an |
| 1965 | // exception (caught by the installer) to prevent a fatal error later on. |
| 1966 | if ( !class_exists( Less_Parser::class ) ) { |
| 1967 | throw new RuntimeException( 'MediaWiki requires the less.php parser' ); |
| 1968 | } |
| 1969 | |
| 1970 | $importDirs[] = MW_INSTALL_PATH . '/resources/src/mediawiki.less'; |
| 1971 | |
| 1972 | $parser = new Less_Parser; |
| 1973 | $parser->ModifyVars( $vars ); |
| 1974 | $parser->SetOption( 'relativeUrls', false ); |
| 1975 | $parser->SetOption( 'math', 'parens-division' ); |
| 1976 | |
| 1977 | // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ] |
| 1978 | $formattedImportDirs = array_fill_keys( $importDirs, '' ); |
| 1979 | |
| 1980 | // Add a callback to the import dirs array for path remapping |
| 1981 | $codexDevDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir ); |
| 1982 | $formattedImportDirs[] = static function ( $path ) use ( $codexDevDir ) { |
| 1983 | // For each of the Codex import paths, use CodexDevelopmentDir if it's set |
| 1984 | $importMap = [ |
| 1985 | '@wikimedia/codex-icons/' => $codexDevDir !== null ? |
| 1986 | "$codexDevDir/packages/codex-icons/dist/" : |
| 1987 | MW_INSTALL_PATH . '/resources/lib/codex-icons/', |
| 1988 | 'mediawiki.skin.codex/' => $codexDevDir !== null ? |
| 1989 | "$codexDevDir/packages/codex/dist/" : |
| 1990 | MW_INSTALL_PATH . '/resources/lib/codex/', |
| 1991 | 'mediawiki.skin.codex-design-tokens/' => $codexDevDir !== null ? |
| 1992 | "$codexDevDir/packages/codex-design-tokens/dist/" : |
| 1993 | MW_INSTALL_PATH . '/resources/lib/codex-design-tokens/', |
| 1994 | '@wikimedia/codex-design-tokens/' => static function ( $unused_path ): never { |
| 1995 | throw new RuntimeException( |
| 1996 | 'Importing from @wikimedia/codex-design-tokens is not supported. ' . |
| 1997 | "To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead." |
| 1998 | ); |
| 1999 | } |
| 2000 | ]; |
| 2001 | foreach ( $importMap as $importPath => $substPath ) { |
| 2002 | if ( str_starts_with( $path, $importPath ) ) { |
| 2003 | $restOfPath = substr( $path, strlen( $importPath ) ); |
| 2004 | if ( is_callable( $substPath ) ) { |
| 2005 | // @phan-suppress-next-line PhanUseReturnValueOfNever |
| 2006 | $resolvedPath = $substPath( $restOfPath ); |
| 2007 | } else { |
| 2008 | $filePath = $substPath . $restOfPath; |
| 2009 | |
| 2010 | $resolvedPath = null; |
| 2011 | if ( file_exists( $filePath ) ) { |
| 2012 | $resolvedPath = $filePath; |
| 2013 | } elseif ( file_exists( "$filePath.less" ) ) { |
| 2014 | $resolvedPath = "$filePath.less"; |
| 2015 | } |
| 2016 | } |
| 2017 | |
| 2018 | if ( $resolvedPath !== null ) { |
| 2019 | return [ |
| 2020 | Less_Environment::normalizePath( $resolvedPath ), |
| 2021 | Less_Environment::normalizePath( dirname( $path ) ) |
| 2022 | ]; |
| 2023 | } else { |
| 2024 | break; |
| 2025 | } |
| 2026 | } |
| 2027 | } |
| 2028 | return [ null, null ]; |
| 2029 | }; |
| 2030 | $parser->SetImportDirs( $formattedImportDirs ); |
| 2031 | |
| 2032 | return $parser; |
| 2033 | } |
| 2034 | |
| 2035 | /** |
| 2036 | * Run JavaScript or CSS data through a filter, caching the filtered result for future calls. |
| 2037 | * |
| 2038 | * Available filters are: |
| 2039 | * |
| 2040 | * - minify-js |
| 2041 | * - minify-css |
| 2042 | * |
| 2043 | * If $data is empty, only contains whitespace or the filter was unknown, |
| 2044 | * $data is returned unmodified. |
| 2045 | * |
| 2046 | * @param string $filter Name of filter to run |
| 2047 | * @param string $data Text to filter, such as JavaScript or CSS text |
| 2048 | * @param array<string,bool> $options Keys: |
| 2049 | * - (bool) cache: Whether to allow caching this data. Default: true. |
| 2050 | * @return string Filtered data or unfiltered data |
| 2051 | */ |
| 2052 | public static function filter( $filter, $data, array $options = [] ) { |
| 2053 | if ( isset( $options['cache'] ) && $options['cache'] === false ) { |
| 2054 | return self::applyFilter( $filter, $data ) ?? $data; |
| 2055 | } |
| 2056 | |
| 2057 | $statsFactory = MediaWikiServices::getInstance()->getStatsFactory(); |
| 2058 | // Same as ResourceLoader->srvCache |
| 2059 | $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache(); |
| 2060 | |
| 2061 | $key = $cache->makeGlobalKey( |
| 2062 | 'resourceloader-filter', |
| 2063 | $filter, |
| 2064 | self::CACHE_VERSION, |
| 2065 | md5( $data ) |
| 2066 | ); |
| 2067 | |
| 2068 | $status = 'hit'; |
| 2069 | $result = $cache->getWithSetCallback( |
| 2070 | $key, |
| 2071 | BagOStuff::TTL_DAY, |
| 2072 | static function () use ( $filter, $data, &$status ) { |
| 2073 | $status = 'miss'; |
| 2074 | return self::applyFilter( $filter, $data ); |
| 2075 | } |
| 2076 | ); |
| 2077 | $statsFactory->getCounter( 'resourceloader_cache_total' ) |
| 2078 | ->setLabel( 'type', $filter ) |
| 2079 | ->setLabel( 'status', $status ) |
| 2080 | ->increment(); |
| 2081 | |
| 2082 | // Use $data on cache failure |
| 2083 | return $result ?? $data; |
| 2084 | } |
| 2085 | |
| 2086 | /** |
| 2087 | * @param string $filter |
| 2088 | * @param string $data |
| 2089 | * @return string|null |
| 2090 | */ |
| 2091 | private static function applyFilter( $filter, $data ) { |
| 2092 | $data = trim( $data ); |
| 2093 | if ( $data ) { |
| 2094 | try { |
| 2095 | $data = ( $filter === 'minify-css' ) |
| 2096 | ? CSSMin::minify( $data ) |
| 2097 | : JavaScriptMinifier::minify( $data ); |
| 2098 | } catch ( TimeoutException $e ) { |
| 2099 | throw $e; |
| 2100 | } catch ( Exception $e ) { |
| 2101 | MWExceptionHandler::logException( $e ); |
| 2102 | return null; |
| 2103 | } |
| 2104 | } |
| 2105 | return $data; |
| 2106 | } |
| 2107 | |
| 2108 | /** |
| 2109 | * Get user default options to expose to JavaScript on all pages via `mw.user.options`. |
| 2110 | * |
| 2111 | * @internal Exposed for use from Resources.php |
| 2112 | * |
| 2113 | * @param Context $context |
| 2114 | * @param HookContainer $hookContainer |
| 2115 | * @param UserOptionsLookup $userOptionsLookup |
| 2116 | * |
| 2117 | * @return array |
| 2118 | */ |
| 2119 | public static function getUserDefaults( |
| 2120 | Context $context, |
| 2121 | HookContainer $hookContainer, |
| 2122 | UserOptionsLookup $userOptionsLookup |
| 2123 | ): array { |
| 2124 | $defaultOptions = $userOptionsLookup->getDefaultOptions(); |
| 2125 | $keysToExclude = []; |
| 2126 | $hookRunner = new HookRunner( $hookContainer ); |
| 2127 | $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context ); |
| 2128 | foreach ( $keysToExclude as $excludedKey ) { |
| 2129 | unset( $defaultOptions[ $excludedKey ] ); |
| 2130 | } |
| 2131 | return $defaultOptions; |
| 2132 | } |
| 2133 | |
| 2134 | /** |
| 2135 | * Get site configuration settings to expose to JavaScript on all pages via `mw.config`. |
| 2136 | * |
| 2137 | * @internal Exposed for use from Resources.php |
| 2138 | * @param Context $context |
| 2139 | * @param Config $conf |
| 2140 | * @return array |
| 2141 | */ |
| 2142 | public static function getSiteConfigSettings( |
| 2143 | Context $context, Config $conf |
| 2144 | ): array { |
| 2145 | $services = MediaWikiServices::getInstance(); |
| 2146 | // Namespace related preparation |
| 2147 | // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces. |
| 2148 | // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive. |
| 2149 | $contLang = $services->getContentLanguage(); |
| 2150 | $namespaceIds = $contLang->getNamespaceIds(); |
| 2151 | $caseSensitiveNamespaces = []; |
| 2152 | $nsInfo = $services->getNamespaceInfo(); |
| 2153 | foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) { |
| 2154 | $namespaceIds[$contLang->lc( $name )] = $index; |
| 2155 | if ( !$nsInfo->isCapitalized( $index ) ) { |
| 2156 | $caseSensitiveNamespaces[] = $index; |
| 2157 | } |
| 2158 | } |
| 2159 | |
| 2160 | $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars ); |
| 2161 | |
| 2162 | // Build list of variables |
| 2163 | $skin = $context->getSkin(); |
| 2164 | |
| 2165 | // Start of supported and stable config vars (for use by extensions/gadgets). |
| 2166 | $vars = [ |
| 2167 | 'debug' => $context->getDebug(), |
| 2168 | 'skin' => $skin, |
| 2169 | 'stylepath' => $conf->get( MainConfigNames::StylePath ), |
| 2170 | 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ), |
| 2171 | 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ), |
| 2172 | 'wgScript' => $conf->get( MainConfigNames::Script ), |
| 2173 | 'wgSearchType' => $conf->get( MainConfigNames::SearchType ), |
| 2174 | 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ), |
| 2175 | 'wgServer' => $conf->get( MainConfigNames::Server ), |
| 2176 | 'wgServerName' => $conf->get( MainConfigNames::ServerName ), |
| 2177 | 'wgUserLanguage' => $context->getLanguage(), |
| 2178 | 'wgContentLanguage' => $contLang->getCode(), |
| 2179 | 'wgVersion' => MW_VERSION, |
| 2180 | 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(), |
| 2181 | 'wgNamespaceIds' => $namespaceIds, |
| 2182 | 'wgContentNamespaces' => $nsInfo->getContentNamespaces(), |
| 2183 | 'wgSiteName' => $conf->get( MainConfigNames::Sitename ), |
| 2184 | 'wgDBname' => $conf->get( MainConfigNames::DBname ), |
| 2185 | 'wgWikiID' => WikiMap::getCurrentWikiId(), |
| 2186 | 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces, |
| 2187 | 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT, |
| 2188 | 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ), |
| 2189 | ]; |
| 2190 | // End of stable config vars. |
| 2191 | |
| 2192 | // Internal variables for use by MediaWiki core and/or ResourceLoader. |
| 2193 | $vars += [ |
| 2194 | // @internal For mediawiki.widgets |
| 2195 | 'wgUrlProtocols' => $services->getUrlUtils()->validProtocols(), |
| 2196 | // @internal For mediawiki.page.watch |
| 2197 | // Force object to avoid "empty" associative array from |
| 2198 | // becoming [] instead of {} in JS (T36604) |
| 2199 | 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ), |
| 2200 | // @internal For mediawiki.language |
| 2201 | 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ), |
| 2202 | // @internal For mediawiki.Title |
| 2203 | 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ), |
| 2204 | 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), |
| 2205 | 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ), |
| 2206 | ]; |
| 2207 | |
| 2208 | ( new HookRunner( $services->getHookContainer() ) ) |
| 2209 | ->onResourceLoaderGetConfigVars( $vars, $skin, $conf ); |
| 2210 | |
| 2211 | return $vars; |
| 2212 | } |
| 2213 | |
| 2214 | /** |
| 2215 | * @internal For testing |
| 2216 | * @return array |
| 2217 | */ |
| 2218 | public function getErrors() { |
| 2219 | return $this->errors; |
| 2220 | } |
| 2221 | } |