ResourceLoaderWikiModule::setTitleInfo()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Abstraction for ResourceLoader modules that pull from wiki pages.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @author Trevor Parscal
22
 * @author Roan Kattouw
23
 */
24
25
/**
26
 * Abstraction for ResourceLoader modules which pull from wiki pages
27
 *
28
 * This can only be used for wiki pages in the MediaWiki and User namespaces,
29
 * because of its dependence on the functionality of Title::isCssJsSubpage
30
 * and Title::isCssOrJsPage().
31
 *
32
 * This module supports being used as a placeholder for a module on a remote wiki.
33
 * To do so, getDB() must be overloaded to return a foreign database object that
34
 * allows local wikis to query page metadata.
35
 *
36
 * Safe for calls on local wikis are:
37
 * - Option getters:
38
 *   - getGroup()
39
 *   - getPosition()
40
 *   - getPages()
41
 * - Basic methods that strictly involve the foreign database
42
 *   - getDB()
43
 *   - isKnownEmpty()
44
 *   - getTitleInfo()
45
 */
46
class ResourceLoaderWikiModule extends ResourceLoaderModule {
47
	/** @var string Position on the page to load this module at */
48
	protected $position = 'bottom';
49
50
	// Origin defaults to users with sitewide authority
51
	protected $origin = self::ORIGIN_USER_SITEWIDE;
52
53
	// In-process cache for title info
54
	protected $titleInfo = [];
55
56
	// List of page names that contain CSS
57
	protected $styles = [];
58
59
	// List of page names that contain JavaScript
60
	protected $scripts = [];
61
62
	// Group of module
63
	protected $group;
64
65
	/**
66
	 * @param array $options For back-compat, this can be omitted in favour of overwriting getPages.
67
	 */
68
	public function __construct( array $options = null ) {
69
		if ( is_null( $options ) ) {
70
			return;
71
		}
72
73
		foreach ( $options as $member => $option ) {
74
			switch ( $member ) {
75
				case 'position':
76
				case 'styles':
77
				case 'scripts':
78
				case 'group':
79
				case 'targets':
80
					$this->{$member} = $option;
81
					break;
82
			}
83
		}
84
	}
85
86
	/**
87
	 * Subclasses should return an associative array of resources in the module.
88
	 * Keys should be the title of a page in the MediaWiki or User namespace.
89
	 *
90
	 * Values should be a nested array of options.  The supported keys are 'type' and
91
	 * (CSS only) 'media'.
92
	 *
93
	 * For scripts, 'type' should be 'script'.
94
	 *
95
	 * For stylesheets, 'type' should be 'style'.
96
	 * There is an optional media key, the value of which can be the
97
	 * medium ('screen', 'print', etc.) of the stylesheet.
98
	 *
99
	 * @param ResourceLoaderContext $context
100
	 * @return array
101
	 */
102
	protected function getPages( ResourceLoaderContext $context ) {
103
		$config = $this->getConfig();
104
		$pages = [];
105
106
		// Filter out pages from origins not allowed by the current wiki configuration.
107
		if ( $config->get( 'UseSiteJs' ) ) {
108
			foreach ( $this->scripts as $script ) {
109
				$pages[$script] = [ 'type' => 'script' ];
110
			}
111
		}
112
113
		if ( $config->get( 'UseSiteCss' ) ) {
114
			foreach ( $this->styles as $style ) {
115
				$pages[$style] = [ 'type' => 'style' ];
116
			}
117
		}
118
119
		return $pages;
120
	}
121
122
	/**
123
	 * Get group name
124
	 *
125
	 * @return string
126
	 */
127
	public function getGroup() {
128
		return $this->group;
129
	}
130
131
	/**
132
	 * Get the Database object used in getTitleInfo().
133
	 *
134
	 * Defaults to the local replica DB. Subclasses may want to override this to return a foreign
135
	 * database object, or null if getTitleInfo() shouldn't access the database.
136
	 *
137
	 * NOTE: This ONLY works for getTitleInfo() and isKnownEmpty(), NOT FOR ANYTHING ELSE.
138
	 * In particular, it doesn't work for getContent() or getScript() etc.
139
	 *
140
	 * @return IDatabase|null
141
	 */
142
	protected function getDB() {
143
		return wfGetDB( DB_REPLICA );
144
	}
145
146
	/**
147
	 * @param string $titleText
148
	 * @return null|string
149
	 */
150
	protected function getContent( $titleText ) {
151
		$title = Title::newFromText( $titleText );
152
		if ( !$title ) {
153
			return null;
154
		}
155
156
		$handler = ContentHandler::getForTitle( $title );
157
		if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
158
			$format = CONTENT_FORMAT_CSS;
159
		} elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
160
			$format = CONTENT_FORMAT_JAVASCRIPT;
161
		} else {
162
			return null;
163
		}
164
165
		$revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title->getArticleID(),
0 ignored issues
show
Bug introduced by
It seems like wfGetDB(DB_REPLICA) can be null; however, newKnownCurrent() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
166
			$title->getLatestRevID() );
167
		if ( !$revision ) {
168
			return null;
169
		}
170
		$revision->setTitle( $title );
171
		$content = $revision->getContent( Revision::RAW );
172
173
		if ( !$content ) {
174
			wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' );
175
			return null;
176
		}
177
178
		return $content->serialize( $format );
179
	}
180
181
	/**
182
	 * @param ResourceLoaderContext $context
183
	 * @return string
184
	 */
185
	public function getScript( ResourceLoaderContext $context ) {
186
		$scripts = '';
187
		foreach ( $this->getPages( $context ) as $titleText => $options ) {
188
			if ( $options['type'] !== 'script' ) {
189
				continue;
190
			}
191
			$script = $this->getContent( $titleText );
192
			if ( strval( $script ) !== '' ) {
193
				$script = $this->validateScriptFile( $titleText, $script );
194
				$scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
195
			}
196
		}
197
		return $scripts;
198
	}
199
200
	/**
201
	 * @param ResourceLoaderContext $context
202
	 * @return array
203
	 */
204
	public function getStyles( ResourceLoaderContext $context ) {
205
		$styles = [];
206
		foreach ( $this->getPages( $context ) as $titleText => $options ) {
207
			if ( $options['type'] !== 'style' ) {
208
				continue;
209
			}
210
			$media = isset( $options['media'] ) ? $options['media'] : 'all';
211
			$style = $this->getContent( $titleText );
212
			if ( strval( $style ) === '' ) {
213
				continue;
214
			}
215
			if ( $this->getFlip( $context ) ) {
216
				$style = CSSJanus::transform( $style, true, false );
217
			}
218
			$style = MemoizedCallable::call( 'CSSMin::remap',
219
				[ $style, false, $this->getConfig()->get( 'ScriptPath' ), true ] );
220
			if ( !isset( $styles[$media] ) ) {
221
				$styles[$media] = [];
222
			}
223
			$style = ResourceLoader::makeComment( $titleText ) . $style;
224
			$styles[$media][] = $style;
225
		}
226
		return $styles;
227
	}
228
229
	/**
230
	 * Disable module content versioning.
231
	 *
232
	 * This class does not support generating content outside of a module
233
	 * request due to foreign database support.
234
	 *
235
	 * See getDefinitionSummary() for meta-data versioning.
236
	 *
237
	 * @return bool
238
	 */
239
	public function enableModuleContentVersion() {
240
		return false;
241
	}
242
243
	/**
244
	 * @param ResourceLoaderContext $context
245
	 * @return array
246
	 */
247
	public function getDefinitionSummary( ResourceLoaderContext $context ) {
248
		$summary = parent::getDefinitionSummary( $context );
249
		$summary[] = [
250
			'pages' => $this->getPages( $context ),
251
			// Includes meta data of current revisions
252
			'titleInfo' => $this->getTitleInfo( $context ),
253
		];
254
		return $summary;
255
	}
256
257
	/**
258
	 * @param ResourceLoaderContext $context
259
	 * @return bool
260
	 */
261
	public function isKnownEmpty( ResourceLoaderContext $context ) {
262
		$revisions = $this->getTitleInfo( $context );
263
264
		// For user modules, don't needlessly load if there are no non-empty pages
265
		if ( $this->getGroup() === 'user' ) {
266
			foreach ( $revisions as $revision ) {
267
				if ( $revision['page_len'] > 0 ) {
268
					// At least one non-empty page, module should be loaded
269
					return false;
270
				}
271
			}
272
			return true;
273
		}
274
275
		// Bug 68488: For other modules (i.e. ones that are called in cached html output) only check
276
		// page existance. This ensures that, if some pages in a module are temporarily blanked,
277
		// we don't end omit the module's script or link tag on some pages.
278
		return count( $revisions ) === 0;
279
	}
280
281
	private function setTitleInfo( $key, array $titleInfo ) {
282
		$this->titleInfo[$key] = $titleInfo;
283
	}
284
285
	/**
286
	 * Get the information about the wiki pages for a given context.
287
	 * @param ResourceLoaderContext $context
288
	 * @return array Keyed by page name
289
	 */
290
	protected function getTitleInfo( ResourceLoaderContext $context ) {
291
		$dbr = $this->getDB();
292
		if ( !$dbr ) {
293
			// We're dealing with a subclass that doesn't have a DB
294
			return [];
295
		}
296
297
		$pageNames = array_keys( $this->getPages( $context ) );
298
		sort( $pageNames );
299
		$key = implode( '|', $pageNames );
300
		if ( !isset( $this->titleInfo[$key] ) ) {
301
			$this->titleInfo[$key] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
302
		}
303
		return $this->titleInfo[$key];
304
	}
305
306
	protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
307
		$titleInfo = [];
308
		$batch = new LinkBatch;
309
		foreach ( $pages as $titleText ) {
310
			$title = Title::newFromText( $titleText );
311
			if ( $title ) {
312
				// Page name may be invalid if user-provided (e.g. gadgets)
313
				$batch->addObj( $title );
314
			}
315
		}
316
		if ( !$batch->isEmpty() ) {
317
			$res = $db->select( 'page',
318
				// Include page_touched to allow purging if cache is poisoned (T117587, T113916)
319
				[ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ],
320
				$batch->constructSet( 'page', $db ),
0 ignored issues
show
Bug introduced by
It seems like $batch->constructSet('page', $db) targeting LinkBatch::constructSet() can also be of type boolean; however, IDatabase::select() does only seem to accept string|array, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
321
				$fname
322
			);
323
			foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type object<ResultWrapper>|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
324
				// Avoid including ids or timestamps of revision/page tables so
325
				// that versions are not wasted
326
				$title = Title::makeTitle( $row->page_namespace, $row->page_title );
327
				$titleInfo[$title->getPrefixedText()] = [
328
					'page_len' => $row->page_len,
329
					'page_latest' => $row->page_latest,
330
					'page_touched' => $row->page_touched,
331
				];
332
			}
333
		}
334
		return $titleInfo;
335
	}
336
337
	/**
338
	 * @since 1.28
339
	 * @param ResourceLoaderContext $context
340
	 * @param IDatabase $db
341
	 * @param string[] $moduleNames
342
	 */
343
	public static function preloadTitleInfo(
344
		ResourceLoaderContext $context, IDatabase $db, array $moduleNames
345
	) {
346
		$rl = $context->getResourceLoader();
347
		// getDB() can be overridden to point to a foreign database.
348
		// For now, only preload local. In the future, we could preload by wikiID.
349
		$allPages = [];
350
		/** @var ResourceLoaderWikiModule[] $wikiModules */
351
		$wikiModules = [];
352
		foreach ( $moduleNames as $name ) {
353
			$module = $rl->getModule( $name );
354
			if ( $module instanceof self ) {
355
				$mDB = $module->getDB();
356
				// Subclasses may disable getDB and implement getTitleInfo differently
357
				if ( $mDB && $mDB->getWikiID() === $db->getWikiID() ) {
358
					$wikiModules[] = $module;
359
					$allPages += $module->getPages( $context );
360
				}
361
			}
362
		}
363
364
		$pageNames = array_keys( $allPages );
365
		sort( $pageNames );
366
		$hash = sha1( implode( '|', $pageNames ) );
367
368
		// Avoid Zend bug where "static::" does not apply LSB in the closure
369
		$func = [ static::class, 'fetchTitleInfo' ];
370
		$fname = __METHOD__;
371
372
		$cache = ObjectCache::getMainWANInstance();
373
		$allInfo = $cache->getWithSetCallback(
374
			$cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getWikiID(), $hash ),
375
			$cache::TTL_HOUR,
376
			function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
377
				$setOpts += Database::getCacheSetOptions( $db );
378
379
				return call_user_func( $func, $db, $pageNames, $fname );
380
			},
381
			[ 'checkKeys' => [ $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getWikiID() ) ] ]
382
		);
383
384
		foreach ( $wikiModules as $wikiModule ) {
385
			$pages = $wikiModule->getPages( $context );
386
			// Before we intersect, map the names to canonical form (T145673).
387
			$intersect = [];
388
			foreach ( $pages as $page => $unused ) {
389
				$title = Title::newFromText( $page );
390
				if ( $title ) {
391
					$intersect[ $title->getPrefixedText() ] = 1;
392
				} else {
393
					// Page name may be invalid if user-provided (e.g. gadgets)
394
					$rl->getLogger()->info(
395
						'Invalid wiki page title "{title}" in ' . __METHOD__,
396
						[ 'title' => $page ]
397
					);
398
				}
399
			}
400
			$info = array_intersect_key( $allInfo, $intersect );
401
			$pageNames = array_keys( $pages );
402
			sort( $pageNames );
403
			$key = implode( '|', $pageNames );
404
			$wikiModule->setTitleInfo( $key, $info );
405
		}
406
	}
407
408
	/**
409
	 * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on
410
	 * page change if it was a JS or CSS page
411
	 *
412
	 * @param Title $title
413
	 * @param Revision|null $old Prior page revision
414
	 * @param Revision|null $new New page revision
415
	 * @param string $wikiId
416
	 * @since 1.28
417
	 */
418
	public static function invalidateModuleCache(
419
		Title $title, Revision $old = null, Revision $new = null, $wikiId
420
	) {
421
		static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ];
422
423
		if ( $old && in_array( $old->getContentFormat(), $formats ) ) {
424
			$purge = true;
425
		} elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) {
426
			$purge = true;
427
		} else {
428
			$purge = ( $title->isCssOrJsPage() || $title->isCssJsSubpage() );
429
		}
430
431
		if ( $purge ) {
432
			$cache = ObjectCache::getMainWANInstance();
433
			$key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $wikiId );
434
			$cache->touchCheckKey( $key );
435
		}
436
	}
437
438
	/**
439
	 * @return string
440
	 */
441
	public function getPosition() {
442
		return $this->position;
443
	}
444
445
	/**
446
	 * @since 1.28
447
	 * @return string
448
	 */
449
	public function getType() {
450
		// Check both because subclasses don't always pass pages via the constructor,
451
		// they may also override getPages() instead, in which case we should keep
452
		// defaulting to LOAD_GENERAL and allow them to override getType() separately.
453
		return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
454
	}
455
}
456