Completed
Branch master (537795)
by
unknown
33:10
created

ResourceLoaderWikiModule::invalidateModuleCache()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 13
nc 8
nop 4
dl 0
loc 19
rs 8.2222
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::newFromTitle( $title, false, Revision::READ_NORMAL );
166
		if ( !$revision ) {
167
			return null;
168
		}
169
170
		$content = $revision->getContent( Revision::RAW );
171
172
		if ( !$content ) {
173
			wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' );
174
			return null;
175
		}
176
177
		return $content->serialize( $format );
178
	}
179
180
	/**
181
	 * @param ResourceLoaderContext $context
182
	 * @return string
183
	 */
184
	public function getScript( ResourceLoaderContext $context ) {
185
		$scripts = '';
186
		foreach ( $this->getPages( $context ) as $titleText => $options ) {
187
			if ( $options['type'] !== 'script' ) {
188
				continue;
189
			}
190
			$script = $this->getContent( $titleText );
191
			if ( strval( $script ) !== '' ) {
192
				$script = $this->validateScriptFile( $titleText, $script );
193
				$scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
194
			}
195
		}
196
		return $scripts;
197
	}
198
199
	/**
200
	 * @param ResourceLoaderContext $context
201
	 * @return array
202
	 */
203
	public function getStyles( ResourceLoaderContext $context ) {
204
		$styles = [];
205
		foreach ( $this->getPages( $context ) as $titleText => $options ) {
206
			if ( $options['type'] !== 'style' ) {
207
				continue;
208
			}
209
			$media = isset( $options['media'] ) ? $options['media'] : 'all';
210
			$style = $this->getContent( $titleText );
211
			if ( strval( $style ) === '' ) {
212
				continue;
213
			}
214
			if ( $this->getFlip( $context ) ) {
215
				$style = CSSJanus::transform( $style, true, false );
216
			}
217
			$style = MemoizedCallable::call( 'CSSMin::remap',
218
				[ $style, false, $this->getConfig()->get( 'ScriptPath' ), true ] );
219
			if ( !isset( $styles[$media] ) ) {
220
				$styles[$media] = [];
221
			}
222
			$style = ResourceLoader::makeComment( $titleText ) . $style;
223
			$styles[$media][] = $style;
224
		}
225
		return $styles;
226
	}
227
228
	/**
229
	 * Disable module content versioning.
230
	 *
231
	 * This class does not support generating content outside of a module
232
	 * request due to foreign database support.
233
	 *
234
	 * See getDefinitionSummary() for meta-data versioning.
235
	 *
236
	 * @return bool
237
	 */
238
	public function enableModuleContentVersion() {
239
		return false;
240
	}
241
242
	/**
243
	 * @param ResourceLoaderContext $context
244
	 * @return array
245
	 */
246
	public function getDefinitionSummary( ResourceLoaderContext $context ) {
247
		$summary = parent::getDefinitionSummary( $context );
248
		$summary[] = [
249
			'pages' => $this->getPages( $context ),
250
			// Includes meta data of current revisions
251
			'titleInfo' => $this->getTitleInfo( $context ),
252
		];
253
		return $summary;
254
	}
255
256
	/**
257
	 * @param ResourceLoaderContext $context
258
	 * @return bool
259
	 */
260
	public function isKnownEmpty( ResourceLoaderContext $context ) {
261
		$revisions = $this->getTitleInfo( $context );
262
263
		// For user modules, don't needlessly load if there are no non-empty pages
264
		if ( $this->getGroup() === 'user' ) {
265
			foreach ( $revisions as $revision ) {
266
				if ( $revision['page_len'] > 0 ) {
267
					// At least one non-empty page, module should be loaded
268
					return false;
269
				}
270
			}
271
			return true;
272
		}
273
274
		// Bug 68488: For other modules (i.e. ones that are called in cached html output) only check
275
		// page existance. This ensures that, if some pages in a module are temporarily blanked,
276
		// we don't end omit the module's script or link tag on some pages.
277
		return count( $revisions ) === 0;
278
	}
279
280
	private function setTitleInfo( $key, array $titleInfo ) {
281
		$this->titleInfo[$key] = $titleInfo;
282
	}
283
284
	/**
285
	 * Get the information about the wiki pages for a given context.
286
	 * @param ResourceLoaderContext $context
287
	 * @return array Keyed by page name
288
	 */
289
	protected function getTitleInfo( ResourceLoaderContext $context ) {
290
		$dbr = $this->getDB();
291
		if ( !$dbr ) {
292
			// We're dealing with a subclass that doesn't have a DB
293
			return [];
294
		}
295
296
		$pageNames = array_keys( $this->getPages( $context ) );
297
		sort( $pageNames );
298
		$key = implode( '|', $pageNames );
299
		if ( !isset( $this->titleInfo[$key] ) ) {
300
			$this->titleInfo[$key] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
301
		}
302
		return $this->titleInfo[$key];
303
	}
304
305
	protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
306
		$titleInfo = [];
307
		$batch = new LinkBatch;
308
		foreach ( $pages as $titleText ) {
309
			$title = Title::newFromText( $titleText );
310
			if ( $title ) {
311
				// Page name may be invalid if user-provided (e.g. gadgets)
312
				$batch->addObj( $title );
313
			}
314
		}
315
		if ( !$batch->isEmpty() ) {
316
			$res = $db->select( 'page',
317
				// Include page_touched to allow purging if cache is poisoned (T117587, T113916)
318
				[ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ],
319
				$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...
320
				$fname
321
			);
322
			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...
323
				// Avoid including ids or timestamps of revision/page tables so
324
				// that versions are not wasted
325
				$title = Title::makeTitle( $row->page_namespace, $row->page_title );
326
				$titleInfo[$title->getPrefixedText()] = [
327
					'page_len' => $row->page_len,
328
					'page_latest' => $row->page_latest,
329
					'page_touched' => $row->page_touched,
330
				];
331
			}
332
		}
333
		return $titleInfo;
334
	}
335
336
	/**
337
	 * @since 1.28
338
	 * @param ResourceLoaderContext $context
339
	 * @param IDatabase $db
340
	 * @param string[] $moduleNames
341
	 */
342
	public static function preloadTitleInfo(
343
		ResourceLoaderContext $context, IDatabase $db, array $moduleNames
344
	) {
345
		$rl = $context->getResourceLoader();
346
		// getDB() can be overridden to point to a foreign database.
347
		// For now, only preload local. In the future, we could preload by wikiID.
348
		$allPages = [];
349
		/** @var ResourceLoaderWikiModule[] $wikiModules */
350
		$wikiModules = [];
351
		foreach ( $moduleNames as $name ) {
352
			$module = $rl->getModule( $name );
353
			if ( $module instanceof self ) {
354
				$mDB = $module->getDB();
355
				// Subclasses may disable getDB and implement getTitleInfo differently
356
				if ( $mDB && $mDB->getWikiID() === $db->getWikiID() ) {
357
					$wikiModules[] = $module;
358
					$allPages += $module->getPages( $context );
359
				}
360
			}
361
		}
362
363
		$pageNames = array_keys( $allPages );
364
		sort( $pageNames );
365
		$hash = sha1( implode( '|', $pageNames ) );
366
367
		// Avoid Zend bug where "static::" does not apply LSB in the closure
368
		$func = [ static::class, 'fetchTitleInfo' ];
369
		$fname = __METHOD__;
370
371
		$cache = ObjectCache::getMainWANInstance();
372
		$allInfo = $cache->getWithSetCallback(
373
			$cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getWikiID(), $hash ),
374
			$cache::TTL_HOUR,
375
			function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
376
				$setOpts += Database::getCacheSetOptions( $db );
377
378
				return call_user_func( $func, $db, $pageNames, $fname );
379
			},
380
			[ 'checkKeys' => [ $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getWikiID() ) ] ]
381
		);
382
383
		foreach ( $wikiModules as $wikiModule ) {
384
			$pages = $wikiModule->getPages( $context );
385
			// Before we intersect, map the names to canonical form (T145673).
386
			$intersect = [];
387
			foreach ( $pages as $page => $unused ) {
388
				$title = Title::newFromText( $page );
389
				if ( $title ) {
390
					$intersect[ $title->getPrefixedText() ] = 1;
391
				} else {
392
					// Page name may be invalid if user-provided (e.g. gadgets)
393
					$rl->getLogger()->info(
394
						'Invalid wiki page title "{title}" in ' . __METHOD__,
395
						[ 'title' => $page ]
396
					);
397
				}
398
			}
399
			$info = array_intersect_key( $allInfo, $intersect );
400
			$pageNames = array_keys( $pages );
401
			sort( $pageNames );
402
			$key = implode( '|', $pageNames );
403
			$wikiModule->setTitleInfo( $key, $info );
404
		}
405
	}
406
407
	/**
408
	 * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on
409
	 * page change if it was a JS or CSS page
410
	 *
411
	 * @param Title $title
412
	 * @param Revision|null $old Prior page revision
413
	 * @param Revision|null $new New page revision
414
	 * @param string $wikiId
415
	 * @since 1.28
416
	 */
417
	public static function invalidateModuleCache(
418
		Title $title, Revision $old = null, Revision $new = null, $wikiId
419
	) {
420
		static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ];
421
422
		if ( $old && in_array( $old->getContentFormat(), $formats ) ) {
423
			$purge = true;
424
		} elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) {
425
			$purge = true;
426
		} else {
427
			$purge = ( $title->isCssOrJsPage() || $title->isCssJsSubpage() );
428
		}
429
430
		if ( $purge ) {
431
			$cache = ObjectCache::getMainWANInstance();
432
			$key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $wikiId );
433
			$cache->touchCheckKey( $key );
434
		}
435
	}
436
437
	/**
438
	 * @return string
439
	 */
440
	public function getPosition() {
441
		return $this->position;
442
	}
443
444
	/**
445
	 * @since 1.28
446
	 * @return string
447
	 */
448
	public function getType() {
449
		// Check both because subclasses don't always pass pages via the constructor,
450
		// they may also override getPages() instead, in which case we should keep
451
		// defaulting to LOAD_GENERAL and allow them to override getType() separately.
452
		return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
453
	}
454
}
455