MediaWiki REL1_37
ResourceLoaderWikiModule.php
Go to the documentation of this file.
1<?php
28use Wikimedia\Minify\CSSMin;
31use Wikimedia\Timestamp\ConvertibleTimestamp;
32
58 protected $origin = self::ORIGIN_USER_SITEWIDE;
59
76 protected $titleInfo = [];
77
79 protected $styles = [];
80
82 protected $scripts = [];
83
85 protected $group;
86
91 public function __construct( array $options = null ) {
92 if ( $options === null ) {
93 return;
94 }
95
96 foreach ( $options as $member => $option ) {
97 switch ( $member ) {
98 case 'styles':
99 case 'scripts':
100 case 'group':
101 case 'targets':
102 $this->{$member} = $option;
103 break;
104 }
105 }
106 }
107
125 protected function getPages( ResourceLoaderContext $context ) {
126 $config = $this->getConfig();
127 $pages = [];
128
129 // Filter out pages from origins not allowed by the current wiki configuration.
130 if ( $config->get( 'UseSiteJs' ) ) {
131 foreach ( $this->scripts as $script ) {
132 $pages[$script] = [ 'type' => 'script' ];
133 }
134 }
135
136 if ( $config->get( 'UseSiteCss' ) ) {
137 foreach ( $this->styles as $style ) {
138 $pages[$style] = [ 'type' => 'style' ];
139 }
140 }
141
142 return $pages;
143 }
144
150 public function getGroup() {
151 return $this->group;
152 }
153
168 protected function getDB() {
169 return wfGetDB( DB_REPLICA );
170 }
171
178 protected function getContent( $titleText, ResourceLoaderContext $context ) {
179 $pageStore = MediaWikiServices::getInstance()->getPageStore();
180 $title = $pageStore->getPageByText( $titleText );
181 if ( !$title ) {
182 return null; // Bad title
183 }
184
185 $content = $this->getContentObj( $title, $context );
186 if ( !$content ) {
187 return null; // No content found
188 }
189
190 $handler = $content->getContentHandler();
191 if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
192 $format = CONTENT_FORMAT_CSS;
193 } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
195 } else {
196 return null; // Bad content model
197 }
198
199 return $content->serialize( $format );
200 }
201
210 protected function getContentObj(
211 PageIdentity $page, ResourceLoaderContext $context, $maxRedirects = null
212 ) {
213 $overrideCallback = $context->getContentOverrideCallback();
214 $content = $overrideCallback ? call_user_func( $overrideCallback, $page ) : null;
215 if ( $content ) {
216 if ( !$content instanceof Content ) {
217 $this->getLogger()->error(
218 'Bad content override for "{title}" in ' . __METHOD__,
219 [ 'title' => (string)$page ]
220 );
221 return null;
222 }
223 } else {
224 $revision = MediaWikiServices::getInstance()
225 ->getRevisionLookup()
226 ->getKnownCurrentRevision( $page );
227 if ( !$revision ) {
228 return null;
229 }
230 $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
231
232 if ( !$content ) {
233 $this->getLogger()->error(
234 'Failed to load content of JS/CSS page "{title}" in ' . __METHOD__,
235 [ 'title' => (string)$page ]
236 );
237 return null;
238 }
239 }
240
241 if ( $content->isRedirect() ) {
242 if ( $maxRedirects === null ) {
243 $maxRedirects = $this->getConfig()->get( 'MaxRedirects' ) ?: 0;
244 }
245 if ( $maxRedirects > 0 ) {
246 $newTitle = $content->getRedirectTarget();
247 return $newTitle ? $this->getContentObj( $newTitle, $context, $maxRedirects - 1 ) : null;
248 }
249 }
250
251 return $content;
252 }
253
258 public function shouldEmbedModule( ResourceLoaderContext $context ) {
259 $overrideCallback = $context->getContentOverrideCallback();
260 if ( $overrideCallback && $this->getSource() === 'local' ) {
261 foreach ( $this->getPages( $context ) as $page => $info ) {
262 $title = Title::newFromText( $page );
263 if ( $title && call_user_func( $overrideCallback, $title ) !== null ) {
264 return true;
265 }
266 }
267 }
268
269 return parent::shouldEmbedModule( $context );
270 }
271
276 public function getScript( ResourceLoaderContext $context ) {
277 $scripts = '';
278 foreach ( $this->getPages( $context ) as $titleText => $options ) {
279 if ( $options['type'] !== 'script' ) {
280 continue;
281 }
282 $script = $this->getContent( $titleText, $context );
283 if ( strval( $script ) !== '' ) {
284 $script = $this->validateScriptFile( $titleText, $script );
285 $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
286 }
287 }
288 return $scripts;
289 }
290
295 public function getStyles( ResourceLoaderContext $context ) {
296 $styles = [];
297 foreach ( $this->getPages( $context ) as $titleText => $options ) {
298 if ( $options['type'] !== 'style' ) {
299 continue;
300 }
301 $media = $options['media'] ?? 'all';
302 $style = $this->getContent( $titleText, $context );
303 if ( strval( $style ) === '' ) {
304 continue;
305 }
306 if ( $this->getFlip( $context ) ) {
307 $style = CSSJanus::transform( $style, true, false );
308 }
309 $remoteDir = $this->getConfig()->get( 'ScriptPath' );
310 if ( $remoteDir === '' ) {
311 // When the site is configured with the script path at the
312 // document root, MediaWiki uses an empty string but that is
313 // not a valid URI path. Expand to a slash to avoid fatals
314 // later in CSSMin::resolveUrl().
315 // See also ResourceLoaderFilePath::extractBasePaths, T282280.
316 $remoteDir = '/';
317 }
318
319 $style = MemoizedCallable::call(
320 [ CSSMin::class, 'remap' ],
321 [ $style, false, $remoteDir, true ]
322 );
323 if ( !isset( $styles[$media] ) ) {
324 $styles[$media] = [];
325 }
326 $style = ResourceLoader::makeComment( $titleText ) . $style;
327 $styles[$media][] = $style;
328 }
329 return $styles;
330 }
331
342 public function enableModuleContentVersion() {
343 return false;
344 }
345
350 public function getDefinitionSummary( ResourceLoaderContext $context ) {
351 $summary = parent::getDefinitionSummary( $context );
352 $summary[] = [
353 'pages' => $this->getPages( $context ),
354 // Includes meta data of current revisions
355 'titleInfo' => $this->getTitleInfo( $context ),
356 ];
357 return $summary;
358 }
359
364 public function isKnownEmpty( ResourceLoaderContext $context ) {
365 $revisions = $this->getTitleInfo( $context );
366
367 // If a module has dependencies it cannot be empty. An empty array will be cast to false
368 if ( $this->getDependencies() ) {
369 return false;
370 }
371 // For user modules, don't needlessly load if there are no non-empty pages
372 if ( $this->getGroup() === 'user' ) {
373 foreach ( $revisions as $revision ) {
374 if ( $revision['page_len'] > 0 ) {
375 // At least one non-empty page, module should be loaded
376 return false;
377 }
378 }
379 return true;
380 }
381
382 // T70488: For other modules (i.e. ones that are called in cached html output) only check
383 // page existance. This ensures that, if some pages in a module are temporarily blanked,
384 // we don't end omit the module's script or link tag on some pages.
385 return count( $revisions ) === 0;
386 }
387
388 private function setTitleInfo( $batchKey, array $titleInfo ) {
389 $this->titleInfo[$batchKey] = $titleInfo;
390 }
391
392 private static function makeTitleKey( LinkTarget $title ) {
393 // Used for keys in titleInfo.
394 return "{$title->getNamespace()}:{$title->getDBkey()}";
395 }
396
402 protected function getTitleInfo( ResourceLoaderContext $context ) {
403 $dbr = $this->getDB();
404
405 $pageNames = array_keys( $this->getPages( $context ) );
406 sort( $pageNames );
407 $batchKey = implode( '|', $pageNames );
408 if ( !isset( $this->titleInfo[$batchKey] ) ) {
409 $this->titleInfo[$batchKey] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
410 }
411
412 $titleInfo = $this->titleInfo[$batchKey];
413
414 // Override the title info from the overrides, if any
415 $overrideCallback = $context->getContentOverrideCallback();
416 if ( $overrideCallback ) {
417 foreach ( $pageNames as $page ) {
418 $title = Title::newFromText( $page );
419 $content = $title ? call_user_func( $overrideCallback, $title ) : null;
420 if ( $content !== null ) {
421 $titleInfo[$title->getPrefixedText()] = [
422 'page_len' => $content->getSize(),
423 'page_latest' => 'TBD', // None available
424 'page_touched' => ConvertibleTimestamp::now( TS_MW ),
425 ];
426 }
427 }
428 }
429
430 return $titleInfo;
431 }
432
439 protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
440 $titleInfo = [];
441 $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
442 $batch = $linkBatchFactory->newLinkBatch();
443 foreach ( $pages as $titleText ) {
444 $title = Title::newFromText( $titleText );
445 if ( $title ) {
446 // Page name may be invalid if user-provided (e.g. gadgets)
447 $batch->addObj( $title );
448 }
449 }
450 if ( !$batch->isEmpty() ) {
451 $res = $db->select( 'page',
452 // Include page_touched to allow purging if cache is poisoned (T117587, T113916)
453 [ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ],
454 $batch->constructSet( 'page', $db ),
455 $fname
456 );
457 foreach ( $res as $row ) {
458 // Avoid including ids or timestamps of revision/page tables so
459 // that versions are not wasted
460 $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
461 $titleInfo[self::makeTitleKey( $title )] = [
462 'page_len' => $row->page_len,
463 'page_latest' => $row->page_latest,
464 'page_touched' => $row->page_touched,
465 ];
466 }
467 }
468 return $titleInfo;
469 }
470
477 public static function preloadTitleInfo(
478 ResourceLoaderContext $context, IDatabase $db, array $moduleNames
479 ) {
480 $rl = $context->getResourceLoader();
481 // getDB() can be overridden to point to a foreign database.
482 // For now, only preload local. In the future, we could preload by wikiID.
483 $allPages = [];
485 $wikiModules = [];
486 foreach ( $moduleNames as $name ) {
487 $module = $rl->getModule( $name );
488 if ( $module instanceof self ) {
489 $mDB = $module->getDB();
490 // Subclasses may implement getDB differently
491 if ( $mDB->getDomainID() === $db->getDomainID() ) {
492 $wikiModules[] = $module;
493 $allPages += $module->getPages( $context );
494 }
495 }
496 }
497
498 if ( !$wikiModules ) {
499 // Nothing to preload
500 return;
501 }
502
503 $pageNames = array_keys( $allPages );
504 sort( $pageNames );
505 $hash = sha1( implode( '|', $pageNames ) );
506
507 // Avoid Zend bug where "static::" does not apply LSB in the closure
508 $func = [ static::class, 'fetchTitleInfo' ];
509 $fname = __METHOD__;
510
511 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
512 $allInfo = $cache->getWithSetCallback(
513 $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ),
514 $cache::TTL_HOUR,
515 static function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
516 $setOpts += Database::getCacheSetOptions( $db );
517
518 return call_user_func( $func, $db, $pageNames, $fname );
519 },
520 [
521 'checkKeys' => [
522 $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID() ) ]
523 ]
524 );
525
526 foreach ( $wikiModules as $wikiModule ) {
527 $pages = $wikiModule->getPages( $context );
528 // Before we intersect, map the names to canonical form (T145673).
529 $intersect = [];
530 foreach ( $pages as $pageName => $unused ) {
531 $title = Title::newFromText( $pageName );
532 if ( $title ) {
533 $intersect[ self::makeTitleKey( $title ) ] = 1;
534 } else {
535 // Page name may be invalid if user-provided (e.g. gadgets)
536 $rl->getLogger()->info(
537 'Invalid wiki page title "{title}" in ' . __METHOD__,
538 [ 'title' => $pageName ]
539 );
540 }
541 }
542 $info = array_intersect_key( $allInfo, $intersect );
543 $pageNames = array_keys( $pages );
544 sort( $pageNames );
545 $batchKey = implode( '|', $pageNames );
546 $wikiModule->setTitleInfo( $batchKey, $info );
547 }
548 }
549
560 public static function invalidateModuleCache(
561 PageIdentity $page,
562 ?RevisionRecord $old,
563 ?RevisionRecord $new,
564 string $domain
565 ) {
566 static $models = [ CONTENT_MODEL_CSS, CONTENT_MODEL_JAVASCRIPT ];
567
568 $purge = false;
569 // TODO: MCR: differentiate between page functionality and content model!
570 // Not all pages containing CSS or JS have to be modules! [PageType]
571 if ( $old ) {
572 $oldModel = $old->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel();
573 if ( in_array( $oldModel, $models ) ) {
574 $purge = true;
575 }
576 }
577
578 if ( !$purge && $new ) {
579 $newModel = $new->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel();
580 if ( in_array( $newModel, $models ) ) {
581 $purge = true;
582 }
583 }
584
585 if ( !$purge ) {
586 $title = Title::castFromPageIdentity( $page );
587 $purge = ( $title->isSiteConfigPage() || $title->isUserConfigPage() );
588 }
589
590 if ( $purge ) {
591 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
592 $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain );
593 $cache->touchCheckKey( $key );
594 }
595 }
596
601 public function getType() {
602 // Check both because subclasses don't always pass pages via the constructor,
603 // they may also override getPages() instead, in which case we should keep
604 // defaulting to LOAD_GENERAL and allow them to override getType() separately.
605 return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
606 }
607}
getDB()
const CONTENT_FORMAT_JAVASCRIPT
For JS pages.
Definition Defines.php:226
const CONTENT_MODEL_CSS
Definition Defines.php:210
const CONTENT_FORMAT_CSS
For CSS pages.
Definition Defines.php:228
const CONTENT_MODEL_JAVASCRIPT
Definition Defines.php:209
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Page revision base class.
getSlot( $role, $audience=self::FOR_PUBLIC, Authority $performer=null)
Returns meta-data for the given slot.
Value object representing a content slot associated with a page revision.
static call( $callable, array $args=[], $ttl=3600)
Shortcut method for creating a MemoizedCallable and invoking it with the specified arguments.
Context object that contains information about the state of a specific ResourceLoader web request.
getContentOverrideCallback()
Return the replaced-content mapping callback.
Abstraction for ResourceLoader modules, with name registration and maxage functionality.
getDependencies(ResourceLoaderContext $context=null)
Get a list of modules this module depends on.
validateScriptFile( $fileName, $contents)
Validate a user-provided JavaScript blob.
getFlip(ResourceLoaderContext $context)
string null $name
Module name.
getSource()
Get the source of this module.
Abstraction for ResourceLoader modules which pull from wiki pages.
array $titleInfo
In-process cache for title info, structured as an array [ <batchKey> // Pipe-separated list of sorted...
getTitleInfo(ResourceLoaderContext $context)
Get the information about the wiki pages for a given context.
getDB()
Get the Database handle used for computing the module version.
static invalidateModuleCache(PageIdentity $page, ?RevisionRecord $old, ?RevisionRecord $new, string $domain)
Clear the preloadTitleInfo() cache for all wiki modules on this wiki on page change if it was a JS or...
static preloadTitleInfo(ResourceLoaderContext $context, IDatabase $db, array $moduleNames)
enableModuleContentVersion()
Disable module content versioning.
getPages(ResourceLoaderContext $context)
Subclasses should return an associative array of resources in the module.
shouldEmbedModule(ResourceLoaderContext $context)
static fetchTitleInfo(IDatabase $db, array $pages, $fname=__METHOD__)
static makeTitleKey(LinkTarget $title)
getDefinitionSummary(ResourceLoaderContext $context)
setTitleInfo( $batchKey, array $titleInfo)
array $scripts
List of page names that contain JavaScript.
getStyles(ResourceLoaderContext $context)
isKnownEmpty(ResourceLoaderContext $context)
string null $group
Group of module.
string $origin
Origin defaults to users with sitewide authority.
getContent( $titleText, ResourceLoaderContext $context)
getContentObj(PageIdentity $page, ResourceLoaderContext $context, $maxRedirects=null)
getScript(ResourceLoaderContext $context)
array $styles
List of page names that contain CSS.
static makeComment( $text)
Generate a CSS or JS comment block.
Represents a page (or page fragment) title within MediaWiki.
Relational database abstraction object.
Definition Database.php:52
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
Base interface for content objects.
Definition Content.php:35
Interface for objects (potentially) representing an editable wiki page.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
getDomainID()
Return the currently selected domain ID.
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
$cache
Definition mcc.php:33
const DB_REPLICA
Definition defines.php:25
$content
Definition router.php:76