Completed
Branch master (939199)
by
unknown
39:35
created

includes/changetags/ChangeTags.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Recent changes tagging.
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
 * @ingroup Change tagging
22
 */
23
24
class ChangeTags {
25
	/**
26
	 * Can't delete tags with more than this many uses. Similar in intent to
27
	 * the bigdelete user right
28
	 * @todo Use the job queue for tag deletion to avoid this restriction
29
	 */
30
	const MAX_DELETE_USES = 5000;
31
32
	/**
33
	 * @var string[]
34
	 */
35
	private static $coreTags = [ 'mw-contentmodelchange' ];
36
37
	/**
38
	 * Creates HTML for the given tags
39
	 *
40
	 * @param string $tags Comma-separated list of tags
41
	 * @param string $page A label for the type of action which is being displayed,
42
	 *   for example: 'history', 'contributions' or 'newpages'
43
	 * @param IContextSource|null $context
44
	 * @note Even though it takes null as a valid argument, an IContextSource is preferred
45
	 *       in a new code, as the null value is subject to change in the future
46
	 * @return array Array with two items: (html, classes)
47
	 *   - html: String: HTML for displaying the tags (empty string when param $tags is empty)
48
	 *   - classes: Array of strings: CSS classes used in the generated html, one class for each tag
49
	 */
50
	public static function formatSummaryRow( $tags, $page, IContextSource $context = null ) {
51
		if ( !$tags ) {
52
			return [ '', [] ];
53
		}
54
		if ( !$context ) {
55
			$context = RequestContext::getMain();
56
		}
57
58
		$classes = [];
59
60
		$tags = explode( ',', $tags );
61
		$displayTags = [];
62
		foreach ( $tags as $tag ) {
63
			if ( !$tag ) {
64
				continue;
65
			}
66
			$description = self::tagDescription( $tag, $context );
67
			if ( $description === false ) {
68
				continue;
69
			}
70
			$displayTags[] = Xml::tags(
71
				'span',
72
				[ 'class' => 'mw-tag-marker ' .
73
								Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
74
				$description
75
			);
76
			$classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
77
		}
78
79
		if ( !$displayTags ) {
80
			return [ '', [] ];
81
		}
82
83
		$markers = $context->msg( 'tag-list-wrapper' )
84
			->numParams( count( $displayTags ) )
85
			->rawParams( $context->getLanguage()->commaList( $displayTags ) )
86
			->parse();
87
		$markers = Xml::tags( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
88
89
		return [ $markers, $classes ];
90
	}
91
92
	/**
93
	 * Get a short description for a tag.
94
	 *
95
	 * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
96
	 * returns the HTML-escaped tag name. Uses the message if the message
97
	 * exists, provided it is not disabled. If the message is disabled,
98
	 * we consider the tag hidden, and return false.
99
	 *
100
	 * @param string $tag Tag
101
	 * @param IContextSource $context
102
	 * @return string|bool Tag description or false if tag is to be hidden.
103
	 * @since 1.25 Returns false if tag is to be hidden.
104
	 */
105
	public static function tagDescription( $tag, IContextSource $context ) {
106
		$msg = $context->msg( "tag-$tag" );
107
		if ( !$msg->exists() ) {
108
			// No such message, so return the HTML-escaped tag name.
109
			return htmlspecialchars( $tag );
110
		}
111
		if ( $msg->isDisabled() ) {
112
			// The message exists but is disabled, hide the tag.
113
			return false;
114
		}
115
116
		// Message exists and isn't disabled, use it.
117
		return $msg->parse();
118
	}
119
120
	/**
121
	 * Add tags to a change given its rc_id, rev_id and/or log_id
122
	 *
123
	 * @param string|string[] $tags Tags to add to the change
124
	 * @param int|null $rc_id The rc_id of the change to add the tags to
125
	 * @param int|null $rev_id The rev_id of the change to add the tags to
126
	 * @param int|null $log_id The log_id of the change to add the tags to
127
	 * @param string $params Params to put in the ct_params field of table 'change_tag'
128
	 * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
129
	 * (this should normally be the case)
130
	 *
131
	 * @throws MWException
132
	 * @return bool False if no changes are made, otherwise true
133
	 */
134
	public static function addTags( $tags, $rc_id = null, $rev_id = null,
135
		$log_id = null, $params = null, RecentChange $rc = null
136
	) {
137
		$result = self::updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
138
		return (bool)$result[0];
139
	}
140
141
	/**
142
	 * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
143
	 * without verifying that the tags exist or are valid. If a tag is present in
144
	 * both $tagsToAdd and $tagsToRemove, it will be removed.
145
	 *
146
	 * This function should only be used by extensions to manipulate tags they
147
	 * have registered using the ListDefinedTags hook. When dealing with user
148
	 * input, call updateTagsWithChecks() instead.
149
	 *
150
	 * @param string|array|null $tagsToAdd Tags to add to the change
151
	 * @param string|array|null $tagsToRemove Tags to remove from the change
152
	 * @param int|null &$rc_id The rc_id of the change to add the tags to.
153
	 * Pass a variable whose value is null if the rc_id is not relevant or unknown.
154
	 * @param int|null &$rev_id The rev_id of the change to add the tags to.
155
	 * Pass a variable whose value is null if the rev_id is not relevant or unknown.
156
	 * @param int|null &$log_id The log_id of the change to add the tags to.
157
	 * Pass a variable whose value is null if the log_id is not relevant or unknown.
158
	 * @param string $params Params to put in the ct_params field of table
159
	 * 'change_tag' when adding tags
160
	 * @param RecentChange|null $rc Recent change being tagged, in case the tagging accompanies
161
	 * the action
162
	 * @param User|null $user Tagging user, in case the tagging is subsequent to the tagged action
163
	 *
164
	 * @throws MWException When $rc_id, $rev_id and $log_id are all null
165
	 * @return array Index 0 is an array of tags actually added, index 1 is an
166
	 * array of tags actually removed, index 2 is an array of tags present on the
167
	 * revision or log entry before any changes were made
168
	 *
169
	 * @since 1.25
170
	 */
171
	public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
172
		&$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
173
		User $user = null
174
	) {
175
176
		$tagsToAdd = array_filter( (array)$tagsToAdd ); // Make sure we're submitting all tags...
177
		$tagsToRemove = array_filter( (array)$tagsToRemove );
178
179
		if ( !$rc_id && !$rev_id && !$log_id ) {
180
			throw new MWException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
181
				'specified when adding or removing a tag from a change!' );
182
		}
183
184
		$dbw = wfGetDB( DB_MASTER );
185
186
		// Might as well look for rcids and so on.
187
		if ( !$rc_id ) {
188
			// Info might be out of date, somewhat fractionally, on replica DB.
189
			// LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
190
			// so use that relation to avoid full table scans.
191
			if ( $log_id ) {
192
				$rc_id = $dbw->selectField(
193
					[ 'logging', 'recentchanges' ],
194
					'rc_id',
195
					[
196
						'log_id' => $log_id,
197
						'rc_timestamp = log_timestamp',
198
						'rc_logid = log_id'
199
					],
200
					__METHOD__
201
				);
202 View Code Duplication
			} elseif ( $rev_id ) {
203
				$rc_id = $dbw->selectField(
204
					[ 'revision', 'recentchanges' ],
205
					'rc_id',
206
					[
207
						'rev_id' => $rev_id,
208
						'rc_timestamp = rev_timestamp',
209
						'rc_this_oldid = rev_id'
210
					],
211
					__METHOD__
212
				);
213
			}
214
		} elseif ( !$log_id && !$rev_id ) {
215
			// Info might be out of date, somewhat fractionally, on replica DB.
216
			$log_id = $dbw->selectField(
217
				'recentchanges',
218
				'rc_logid',
219
				[ 'rc_id' => $rc_id ],
220
				__METHOD__
221
			);
222
			$rev_id = $dbw->selectField(
223
				'recentchanges',
224
				'rc_this_oldid',
225
				[ 'rc_id' => $rc_id ],
226
				__METHOD__
227
			);
228
		}
229
230
		if ( $log_id && !$rev_id ) {
231
			$rev_id = $dbw->selectField(
232
				'log_search',
233
				'ls_value',
234
				[ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ],
235
				__METHOD__
236
			);
237
		} elseif ( !$log_id && $rev_id ) {
238
			$log_id = $dbw->selectField(
239
				'log_search',
240
				'ls_log_id',
241
				[ 'ls_field' => 'associated_rev_id', 'ls_value' => $rev_id ],
242
				__METHOD__
243
			);
244
		}
245
246
		// update the tag_summary row
247
		$prevTags = [];
248
		if ( !self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $rc_id, $rev_id,
249
			$log_id, $prevTags ) ) {
250
251
			// nothing to do
252
			return [ [], [], $prevTags ];
253
		}
254
255
		// insert a row into change_tag for each new tag
256
		if ( count( $tagsToAdd ) ) {
257
			$tagsRows = [];
258 View Code Duplication
			foreach ( $tagsToAdd as $tag ) {
259
				// Filter so we don't insert NULLs as zero accidentally.
260
				// Keep in mind that $rc_id === null means "I don't care/know about the
261
				// rc_id, just delete $tag on this revision/log entry". It doesn't
262
				// mean "only delete tags on this revision/log WHERE rc_id IS NULL".
263
				$tagsRows[] = array_filter(
264
					[
265
						'ct_tag' => $tag,
266
						'ct_rc_id' => $rc_id,
267
						'ct_log_id' => $log_id,
268
						'ct_rev_id' => $rev_id,
269
						'ct_params' => $params
270
					]
271
				);
272
			}
273
274
			$dbw->insert( 'change_tag', $tagsRows, __METHOD__, [ 'IGNORE' ] );
275
		}
276
277
		// delete from change_tag
278
		if ( count( $tagsToRemove ) ) {
279 View Code Duplication
			foreach ( $tagsToRemove as $tag ) {
280
				$conds = array_filter(
281
					[
282
						'ct_tag' => $tag,
283
						'ct_rc_id' => $rc_id,
284
						'ct_log_id' => $log_id,
285
						'ct_rev_id' => $rev_id
286
					]
287
				);
288
				$dbw->delete( 'change_tag', $conds, __METHOD__ );
289
			}
290
		}
291
292
		self::purgeTagUsageCache();
293
294
		Hooks::run( 'ChangeTagsAfterUpdateTags', [ $tagsToAdd, $tagsToRemove, $prevTags,
295
			$rc_id, $rev_id, $log_id, $params, $rc, $user ] );
296
297
		return [ $tagsToAdd, $tagsToRemove, $prevTags ];
298
	}
299
300
	/**
301
	 * Adds or removes a given set of tags to/from the relevant row of the
302
	 * tag_summary table. Modifies the tagsToAdd and tagsToRemove arrays to
303
	 * reflect the tags that were actually added and/or removed.
304
	 *
305
	 * @param array &$tagsToAdd
306
	 * @param array &$tagsToRemove If a tag is present in both $tagsToAdd and
307
	 * $tagsToRemove, it will be removed
308
	 * @param int|null $rc_id Null if not known or not applicable
309
	 * @param int|null $rev_id Null if not known or not applicable
310
	 * @param int|null $log_id Null if not known or not applicable
311
	 * @param array &$prevTags Optionally outputs a list of the tags that were
312
	 * in the tag_summary row to begin with
313
	 * @return bool True if any modifications were made, otherwise false
314
	 * @since 1.25
315
	 */
316
	protected static function updateTagSummaryRow( &$tagsToAdd, &$tagsToRemove,
317
		$rc_id, $rev_id, $log_id, &$prevTags = [] ) {
318
319
		$dbw = wfGetDB( DB_MASTER );
320
321
		$tsConds = array_filter( [
322
			'ts_rc_id' => $rc_id,
323
			'ts_rev_id' => $rev_id,
324
			'ts_log_id' => $log_id
325
		] );
326
327
		// Can't both add and remove a tag at the same time...
328
		$tagsToAdd = array_diff( $tagsToAdd, $tagsToRemove );
329
330
		// Update the summary row.
331
		// $prevTags can be out of date on replica DBs, especially when addTags is called consecutively,
332
		// causing loss of tags added recently in tag_summary table.
333
		$prevTags = $dbw->selectField( 'tag_summary', 'ts_tags', $tsConds, __METHOD__ );
334
		$prevTags = $prevTags ? $prevTags : '';
335
		$prevTags = array_filter( explode( ',', $prevTags ) );
336
337
		// add tags
338
		$tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
339
		$newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
340
341
		// remove tags
342
		$tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
343
		$newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
344
345
		sort( $prevTags );
346
		sort( $newTags );
347
		if ( $prevTags == $newTags ) {
348
			// No change.
349
			return false;
350
		}
351
352
		if ( !$newTags ) {
353
			// no tags left, so delete the row altogether
354
			$dbw->delete( 'tag_summary', $tsConds, __METHOD__ );
355
		} else {
356
			$dbw->replace( 'tag_summary',
357
				[ 'ts_rev_id', 'ts_rc_id', 'ts_log_id' ],
358
				array_filter( array_merge( $tsConds, [ 'ts_tags' => implode( ',', $newTags ) ] ) ),
359
				__METHOD__
360
			);
361
		}
362
363
		return true;
364
	}
365
366
	/**
367
	 * Helper function to generate a fatal status with a 'not-allowed' type error.
368
	 *
369
	 * @param string $msgOne Message key to use in the case of one tag
370
	 * @param string $msgMulti Message key to use in the case of more than one tag
371
	 * @param array $tags Restricted tags (passed as $1 into the message, count of
372
	 * $tags passed as $2)
373
	 * @return Status
374
	 * @since 1.25
375
	 */
376
	protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
377
		$lang = RequestContext::getMain()->getLanguage();
378
		$count = count( $tags );
379
		return Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
380
			$lang->commaList( $tags ), $count );
381
	}
382
383
	/**
384
	 * Is it OK to allow the user to apply all the specified tags at the same time
385
	 * as they edit/make the change?
386
	 *
387
	 * @param array $tags Tags that you are interested in applying
388
	 * @param User|null $user User whose permission you wish to check, or null if
389
	 * you don't care (e.g. maintenance scripts)
390
	 * @return Status
391
	 * @since 1.25
392
	 */
393 View Code Duplication
	public static function canAddTagsAccompanyingChange( array $tags,
394
		User $user = null ) {
395
396
		if ( !is_null( $user ) ) {
397
			if ( !$user->isAllowed( 'applychangetags' ) ) {
398
				return Status::newFatal( 'tags-apply-no-permission' );
399
			} elseif ( $user->isBlocked() ) {
400
				return Status::newFatal( 'tags-apply-blocked' );
401
			}
402
		}
403
404
		// to be applied, a tag has to be explicitly defined
405
		// @todo Allow extensions to define tags that can be applied by users...
406
		$allowedTags = self::listExplicitlyDefinedTags();
407
		$disallowedTags = array_diff( $tags, $allowedTags );
408
		if ( $disallowedTags ) {
409
			return self::restrictedTagError( 'tags-apply-not-allowed-one',
410
				'tags-apply-not-allowed-multi', $disallowedTags );
411
		}
412
413
		return Status::newGood();
414
	}
415
416
	/**
417
	 * Adds tags to a given change, checking whether it is allowed first, but
418
	 * without adding a log entry. Useful for cases where the tag is being added
419
	 * along with the action that generated the change (e.g. tagging an edit as
420
	 * it is being made).
421
	 *
422
	 * Extensions should not use this function, unless directly handling a user
423
	 * request to add a particular tag. Normally, extensions should call
424
	 * ChangeTags::updateTags() instead.
425
	 *
426
	 * @param array $tags Tags to apply
427
	 * @param int|null $rc_id The rc_id of the change to add the tags to
428
	 * @param int|null $rev_id The rev_id of the change to add the tags to
429
	 * @param int|null $log_id The log_id of the change to add the tags to
430
	 * @param string $params Params to put in the ct_params field of table
431
	 * 'change_tag' when adding tags
432
	 * @param User $user Who to give credit for the action
433
	 * @return Status
434
	 * @since 1.25
435
	 */
436
	public static function addTagsAccompanyingChangeWithChecks(
437
		array $tags, $rc_id, $rev_id, $log_id, $params, User $user
438
	) {
439
440
		// are we allowed to do this?
441
		$result = self::canAddTagsAccompanyingChange( $tags, $user );
442
		if ( !$result->isOK() ) {
443
			$result->value = null;
444
			return $result;
445
		}
446
447
		// do it!
448
		self::addTags( $tags, $rc_id, $rev_id, $log_id, $params );
449
450
		return Status::newGood( true );
451
	}
452
453
	/**
454
	 * Is it OK to allow the user to adds and remove the given tags tags to/from a
455
	 * change?
456
	 *
457
	 * @param array $tagsToAdd Tags that you are interested in adding
458
	 * @param array $tagsToRemove Tags that you are interested in removing
459
	 * @param User|null $user User whose permission you wish to check, or null if
460
	 * you don't care (e.g. maintenance scripts)
461
	 * @return Status
462
	 * @since 1.25
463
	 */
464
	public static function canUpdateTags( array $tagsToAdd, array $tagsToRemove,
465
		User $user = null ) {
466
467
		if ( !is_null( $user ) ) {
468
			if ( !$user->isAllowed( 'changetags' ) ) {
469
				return Status::newFatal( 'tags-update-no-permission' );
470
			} elseif ( $user->isBlocked() ) {
471
				return Status::newFatal( 'tags-update-blocked' );
472
			}
473
		}
474
475
		if ( $tagsToAdd ) {
476
			// to be added, a tag has to be explicitly defined
477
			// @todo Allow extensions to define tags that can be applied by users...
478
			$explicitlyDefinedTags = self::listExplicitlyDefinedTags();
479
			$diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
480
			if ( $diff ) {
481
				return self::restrictedTagError( 'tags-update-add-not-allowed-one',
482
					'tags-update-add-not-allowed-multi', $diff );
483
			}
484
		}
485
486
		if ( $tagsToRemove ) {
487
			// to be removed, a tag must not be defined by an extension, or equivalently it
488
			// has to be either explicitly defined or not defined at all
489
			// (assuming no edge case of a tag both explicitly-defined and extension-defined)
490
			$softwareDefinedTags = self::listSoftwareDefinedTags();
491
			$intersect = array_intersect( $tagsToRemove, $softwareDefinedTags );
492
			if ( $intersect ) {
493
				return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
494
					'tags-update-remove-not-allowed-multi', $intersect );
495
			}
496
		}
497
498
		return Status::newGood();
499
	}
500
501
	/**
502
	 * Adds and/or removes tags to/from a given change, checking whether it is
503
	 * allowed first, and adding a log entry afterwards.
504
	 *
505
	 * Includes a call to ChangeTag::canUpdateTags(), so your code doesn't need
506
	 * to do that. However, it doesn't check whether the *_id parameters are a
507
	 * valid combination. That is up to you to enforce. See ApiTag::execute() for
508
	 * an example.
509
	 *
510
	 * @param array|null $tagsToAdd If none, pass array() or null
511
	 * @param array|null $tagsToRemove If none, pass array() or null
512
	 * @param int|null $rc_id The rc_id of the change to add the tags to
513
	 * @param int|null $rev_id The rev_id of the change to add the tags to
514
	 * @param int|null $log_id The log_id of the change to add the tags to
515
	 * @param string $params Params to put in the ct_params field of table
516
	 * 'change_tag' when adding tags
517
	 * @param string $reason Comment for the log
518
	 * @param User $user Who to give credit for the action
519
	 * @return Status If successful, the value of this Status object will be an
520
	 * object (stdClass) with the following fields:
521
	 *  - logId: the ID of the added log entry, or null if no log entry was added
522
	 *    (i.e. no operation was performed)
523
	 *  - addedTags: an array containing the tags that were actually added
524
	 *  - removedTags: an array containing the tags that were actually removed
525
	 * @since 1.25
526
	 */
527
	public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
528
		$rc_id, $rev_id, $log_id, $params, $reason, User $user ) {
529
530
		if ( is_null( $tagsToAdd ) ) {
531
			$tagsToAdd = [];
532
		}
533
		if ( is_null( $tagsToRemove ) ) {
534
			$tagsToRemove = [];
535
		}
536 View Code Duplication
		if ( !$tagsToAdd && !$tagsToRemove ) {
537
			// no-op, don't bother
538
			return Status::newGood( (object)[
539
				'logId' => null,
540
				'addedTags' => [],
541
				'removedTags' => [],
542
			] );
543
		}
544
545
		// are we allowed to do this?
546
		$result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $user );
547
		if ( !$result->isOK() ) {
548
			$result->value = null;
549
			return $result;
550
		}
551
552
		// basic rate limiting
553
		if ( $user->pingLimiter( 'changetag' ) ) {
554
			return Status::newFatal( 'actionthrottledtext' );
555
		}
556
557
		// do it!
558
		list( $tagsAdded, $tagsRemoved, $initialTags ) = self::updateTags( $tagsToAdd,
559
			$tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $user );
560 View Code Duplication
		if ( !$tagsAdded && !$tagsRemoved ) {
561
			// no-op, don't log it
562
			return Status::newGood( (object)[
563
				'logId' => null,
564
				'addedTags' => [],
565
				'removedTags' => [],
566
			] );
567
		}
568
569
		// log it
570
		$logEntry = new ManualLogEntry( 'tag', 'update' );
571
		$logEntry->setPerformer( $user );
572
		$logEntry->setComment( $reason );
573
574
		// find the appropriate target page
575
		if ( $rev_id ) {
576
			$rev = Revision::newFromId( $rev_id );
577
			if ( $rev ) {
578
				$logEntry->setTarget( $rev->getTitle() );
0 ignored issues
show
It seems like $rev->getTitle() can be null; however, setTarget() 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...
579
			}
580
		} elseif ( $log_id ) {
581
			// This function is from revision deletion logic and has nothing to do with
582
			// change tags, but it appears to be the only other place in core where we
583
			// perform logged actions on log items.
584
			$logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
585
		}
586
587
		if ( !$logEntry->getTarget() ) {
588
			// target is required, so we have to set something
589
			$logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
590
		}
591
592
		$logParams = [
593
			'4::revid' => $rev_id,
594
			'5::logid' => $log_id,
595
			'6:list:tagsAdded' => $tagsAdded,
596
			'7:number:tagsAddedCount' => count( $tagsAdded ),
597
			'8:list:tagsRemoved' => $tagsRemoved,
598
			'9:number:tagsRemovedCount' => count( $tagsRemoved ),
599
			'initialTags' => $initialTags,
600
		];
601
		$logEntry->setParameters( $logParams );
602
		$logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
603
604
		$dbw = wfGetDB( DB_MASTER );
605
		$logId = $logEntry->insert( $dbw );
606
		// Only send this to UDP, not RC, similar to patrol events
607
		$logEntry->publish( $logId, 'udp' );
608
609
		return Status::newGood( (object)[
610
			'logId' => $logId,
611
			'addedTags' => $tagsAdded,
612
			'removedTags' => $tagsRemoved,
613
		] );
614
	}
615
616
	/**
617
	 * Applies all tags-related changes to a query.
618
	 * Handles selecting tags, and filtering.
619
	 * Needs $tables to be set up properly, so we can figure out which join conditions to use.
620
	 *
621
	 * @param string|array $tables Table names, see Database::select
622
	 * @param string|array $fields Fields used in query, see Database::select
623
	 * @param string|array $conds Conditions used in query, see Database::select
624
	 * @param array $join_conds Join conditions, see Database::select
625
	 * @param array $options Options, see Database::select
626
	 * @param bool|string $filter_tag Tag to select on
627
	 *
628
	 * @throws MWException When unable to determine appropriate JOIN condition for tagging
629
	 */
630
	public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
631
										&$join_conds, &$options, $filter_tag = false ) {
632
		global $wgRequest, $wgUseTagFilter;
633
634
		if ( $filter_tag === false ) {
635
			$filter_tag = $wgRequest->getVal( 'tagfilter' );
636
		}
637
638
		// Figure out which conditions can be done.
639
		if ( in_array( 'recentchanges', $tables ) ) {
640
			$join_cond = 'ct_rc_id=rc_id';
641
		} elseif ( in_array( 'logging', $tables ) ) {
642
			$join_cond = 'ct_log_id=log_id';
643
		} elseif ( in_array( 'revision', $tables ) ) {
644
			$join_cond = 'ct_rev_id=rev_id';
645
		} elseif ( in_array( 'archive', $tables ) ) {
646
			$join_cond = 'ct_rev_id=ar_rev_id';
647
		} else {
648
			throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
649
		}
650
651
		$fields['ts_tags'] = wfGetDB( DB_REPLICA )->buildGroupConcatField(
652
			',', 'change_tag', 'ct_tag', $join_cond
653
		);
654
655
		if ( $wgUseTagFilter && $filter_tag ) {
656
			// Somebody wants to filter on a tag.
657
			// Add an INNER JOIN on change_tag
658
659
			$tables[] = 'change_tag';
660
			$join_conds['change_tag'] = [ 'INNER JOIN', $join_cond ];
661
			$conds['ct_tag'] = $filter_tag;
662
		}
663
	}
664
665
	/**
666
	 * Build a text box to select a change tag
667
	 *
668
	 * @param string $selected Tag to select by default
669
	 * @param bool $ooui Use an OOUI TextInputWidget as selector instead of a non-OOUI input field
670
	 *        You need to call OutputPage::enableOOUI() yourself.
671
	 * @return array an array of (label, selector)
672
	 */
673
	public static function buildTagFilterSelector( $selected = '', $ooui = false ) {
674
		global $wgUseTagFilter;
675
676
		if ( !$wgUseTagFilter || !count( self::listDefinedTags() ) ) {
677
			return [];
678
		}
679
680
		$data = [
681
			Html::rawElement(
682
				'label',
683
				[ 'for' => 'tagfilter' ],
684
				wfMessage( 'tag-filter' )->parse()
685
			)
686
		];
687
688
		if ( $ooui ) {
689
			$data[] = new OOUI\TextInputWidget( [
690
				'id' => 'tagfilter',
691
				'name' => 'tagfilter',
692
				'value' => $selected,
693
				'classes' => 'mw-tagfilter-input',
694
			] );
695
		} else {
696
			$data[] = Xml::input(
697
				'tagfilter',
698
				20,
699
				$selected,
700
				[ 'class' => 'mw-tagfilter-input mw-ui-input mw-ui-input-inline', 'id' => 'tagfilter' ]
701
			);
702
		}
703
704
		return $data;
705
	}
706
707
	/**
708
	 * Defines a tag in the valid_tag table, without checking that the tag name
709
	 * is valid.
710
	 * Extensions should NOT use this function; they can use the ListDefinedTags
711
	 * hook instead.
712
	 *
713
	 * @param string $tag Tag to create
714
	 * @since 1.25
715
	 */
716 View Code Duplication
	public static function defineTag( $tag ) {
717
		$dbw = wfGetDB( DB_MASTER );
718
		$dbw->replace( 'valid_tag',
719
			[ 'vt_tag' ],
720
			[ 'vt_tag' => $tag ],
721
			__METHOD__ );
722
723
		// clear the memcache of defined tags
724
		self::purgeTagCacheAll();
725
	}
726
727
	/**
728
	 * Removes a tag from the valid_tag table. The tag may remain in use by
729
	 * extensions, and may still show up as 'defined' if an extension is setting
730
	 * it from the ListDefinedTags hook.
731
	 *
732
	 * @param string $tag Tag to remove
733
	 * @since 1.25
734
	 */
735 View Code Duplication
	public static function undefineTag( $tag ) {
736
		$dbw = wfGetDB( DB_MASTER );
737
		$dbw->delete( 'valid_tag', [ 'vt_tag' => $tag ], __METHOD__ );
738
739
		// clear the memcache of defined tags
740
		self::purgeTagCacheAll();
741
	}
742
743
	/**
744
	 * Writes a tag action into the tag management log.
745
	 *
746
	 * @param string $action
747
	 * @param string $tag
748
	 * @param string $reason
749
	 * @param User $user Who to attribute the action to
750
	 * @param int $tagCount For deletion only, how many usages the tag had before
751
	 * it was deleted.
752
	 * @return int ID of the inserted log entry
753
	 * @since 1.25
754
	 */
755
	protected static function logTagManagementAction( $action, $tag, $reason,
756
		User $user, $tagCount = null ) {
757
758
		$dbw = wfGetDB( DB_MASTER );
759
760
		$logEntry = new ManualLogEntry( 'managetags', $action );
761
		$logEntry->setPerformer( $user );
762
		// target page is not relevant, but it has to be set, so we just put in
763
		// the title of Special:Tags
764
		$logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
0 ignored issues
show
It seems like \Title::newFromText('Special:Tags') can be null; however, setTarget() 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...
765
		$logEntry->setComment( $reason );
766
767
		$params = [ '4::tag' => $tag ];
768
		if ( !is_null( $tagCount ) ) {
769
			$params['5:number:count'] = $tagCount;
770
		}
771
		$logEntry->setParameters( $params );
772
		$logEntry->setRelations( [ 'Tag' => $tag ] );
773
774
		$logId = $logEntry->insert( $dbw );
775
		$logEntry->publish( $logId );
776
		return $logId;
777
	}
778
779
	/**
780
	 * Is it OK to allow the user to activate this tag?
781
	 *
782
	 * @param string $tag Tag that you are interested in activating
783
	 * @param User|null $user User whose permission you wish to check, or null if
784
	 * you don't care (e.g. maintenance scripts)
785
	 * @return Status
786
	 * @since 1.25
787
	 */
788
	public static function canActivateTag( $tag, User $user = null ) {
789
		if ( !is_null( $user ) ) {
790
			if ( !$user->isAllowed( 'managechangetags' ) ) {
791
				return Status::newFatal( 'tags-manage-no-permission' );
792
			} elseif ( $user->isBlocked() ) {
793
				return Status::newFatal( 'tags-manage-blocked' );
794
			}
795
		}
796
797
		// defined tags cannot be activated (a defined tag is either extension-
798
		// defined, in which case the extension chooses whether or not to active it;
799
		// or user-defined, in which case it is considered active)
800
		$definedTags = self::listDefinedTags();
801
		if ( in_array( $tag, $definedTags ) ) {
802
			return Status::newFatal( 'tags-activate-not-allowed', $tag );
803
		}
804
805
		// non-existing tags cannot be activated
806
		$tagUsage = self::tagUsageStatistics();
807
		if ( !isset( $tagUsage[$tag] ) ) { // we already know the tag is undefined
808
			return Status::newFatal( 'tags-activate-not-found', $tag );
809
		}
810
811
		return Status::newGood();
812
	}
813
814
	/**
815
	 * Activates a tag, checking whether it is allowed first, and adding a log
816
	 * entry afterwards.
817
	 *
818
	 * Includes a call to ChangeTag::canActivateTag(), so your code doesn't need
819
	 * to do that.
820
	 *
821
	 * @param string $tag
822
	 * @param string $reason
823
	 * @param User $user Who to give credit for the action
824
	 * @param bool $ignoreWarnings Can be used for API interaction, default false
825
	 * @return Status If successful, the Status contains the ID of the added log
826
	 * entry as its value
827
	 * @since 1.25
828
	 */
829 View Code Duplication
	public static function activateTagWithChecks( $tag, $reason, User $user,
830
		$ignoreWarnings = false ) {
831
832
		// are we allowed to do this?
833
		$result = self::canActivateTag( $tag, $user );
834
		if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
835
			$result->value = null;
836
			return $result;
837
		}
838
839
		// do it!
840
		self::defineTag( $tag );
841
842
		// log it
843
		$logId = self::logTagManagementAction( 'activate', $tag, $reason, $user );
844
		return Status::newGood( $logId );
845
	}
846
847
	/**
848
	 * Is it OK to allow the user to deactivate this tag?
849
	 *
850
	 * @param string $tag Tag that you are interested in deactivating
851
	 * @param User|null $user User whose permission you wish to check, or null if
852
	 * you don't care (e.g. maintenance scripts)
853
	 * @return Status
854
	 * @since 1.25
855
	 */
856 View Code Duplication
	public static function canDeactivateTag( $tag, User $user = null ) {
857
		if ( !is_null( $user ) ) {
858
			if ( !$user->isAllowed( 'managechangetags' ) ) {
859
				return Status::newFatal( 'tags-manage-no-permission' );
860
			} elseif ( $user->isBlocked() ) {
861
				return Status::newFatal( 'tags-manage-blocked' );
862
			}
863
		}
864
865
		// only explicitly-defined tags can be deactivated
866
		$explicitlyDefinedTags = self::listExplicitlyDefinedTags();
867
		if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
868
			return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
869
		}
870
		return Status::newGood();
871
	}
872
873
	/**
874
	 * Deactivates a tag, checking whether it is allowed first, and adding a log
875
	 * entry afterwards.
876
	 *
877
	 * Includes a call to ChangeTag::canDeactivateTag(), so your code doesn't need
878
	 * to do that.
879
	 *
880
	 * @param string $tag
881
	 * @param string $reason
882
	 * @param User $user Who to give credit for the action
883
	 * @param bool $ignoreWarnings Can be used for API interaction, default false
884
	 * @return Status If successful, the Status contains the ID of the added log
885
	 * entry as its value
886
	 * @since 1.25
887
	 */
888 View Code Duplication
	public static function deactivateTagWithChecks( $tag, $reason, User $user,
889
		$ignoreWarnings = false ) {
890
891
		// are we allowed to do this?
892
		$result = self::canDeactivateTag( $tag, $user );
893
		if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
894
			$result->value = null;
895
			return $result;
896
		}
897
898
		// do it!
899
		self::undefineTag( $tag );
900
901
		// log it
902
		$logId = self::logTagManagementAction( 'deactivate', $tag, $reason, $user );
903
		return Status::newGood( $logId );
904
	}
905
906
	/**
907
	 * Is it OK to allow the user to create this tag?
908
	 *
909
	 * @param string $tag Tag that you are interested in creating
910
	 * @param User|null $user User whose permission you wish to check, or null if
911
	 * you don't care (e.g. maintenance scripts)
912
	 * @return Status
913
	 * @since 1.25
914
	 */
915
	public static function canCreateTag( $tag, User $user = null ) {
916
		if ( !is_null( $user ) ) {
917
			if ( !$user->isAllowed( 'managechangetags' ) ) {
918
				return Status::newFatal( 'tags-manage-no-permission' );
919
			} elseif ( $user->isBlocked() ) {
920
				return Status::newFatal( 'tags-manage-blocked' );
921
			}
922
		}
923
924
		// no empty tags
925
		if ( $tag === '' ) {
926
			return Status::newFatal( 'tags-create-no-name' );
927
		}
928
929
		// tags cannot contain commas (used as a delimiter in tag_summary table) or
930
		// slashes (would break tag description messages in MediaWiki namespace)
931
		if ( strpos( $tag, ',' ) !== false || strpos( $tag, '/' ) !== false ) {
932
			return Status::newFatal( 'tags-create-invalid-chars' );
933
		}
934
935
		// could the MediaWiki namespace description messages be created?
936
		$title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
937
		if ( is_null( $title ) ) {
938
			return Status::newFatal( 'tags-create-invalid-title-chars' );
939
		}
940
941
		// does the tag already exist?
942
		$tagUsage = self::tagUsageStatistics();
943 View Code Duplication
		if ( isset( $tagUsage[$tag] ) || in_array( $tag, self::listDefinedTags() ) ) {
944
			return Status::newFatal( 'tags-create-already-exists', $tag );
945
		}
946
947
		// check with hooks
948
		$canCreateResult = Status::newGood();
949
		Hooks::run( 'ChangeTagCanCreate', [ $tag, $user, &$canCreateResult ] );
950
		return $canCreateResult;
951
	}
952
953
	/**
954
	 * Creates a tag by adding a row to the `valid_tag` table.
955
	 *
956
	 * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
957
	 * do that.
958
	 *
959
	 * @param string $tag
960
	 * @param string $reason
961
	 * @param User $user Who to give credit for the action
962
	 * @param bool $ignoreWarnings Can be used for API interaction, default false
963
	 * @return Status If successful, the Status contains the ID of the added log
964
	 * entry as its value
965
	 * @since 1.25
966
	 */
967 View Code Duplication
	public static function createTagWithChecks( $tag, $reason, User $user,
968
		$ignoreWarnings = false ) {
969
970
		// are we allowed to do this?
971
		$result = self::canCreateTag( $tag, $user );
972
		if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
973
			$result->value = null;
974
			return $result;
975
		}
976
977
		// do it!
978
		self::defineTag( $tag );
979
980
		// log it
981
		$logId = self::logTagManagementAction( 'create', $tag, $reason, $user );
982
		return Status::newGood( $logId );
983
	}
984
985
	/**
986
	 * Permanently removes all traces of a tag from the DB. Good for removing
987
	 * misspelt or temporary tags.
988
	 *
989
	 * This function should be directly called by maintenance scripts only, never
990
	 * by user-facing code. See deleteTagWithChecks() for functionality that can
991
	 * safely be exposed to users.
992
	 *
993
	 * @param string $tag Tag to remove
994
	 * @return Status The returned status will be good unless a hook changed it
995
	 * @since 1.25
996
	 */
997
	public static function deleteTagEverywhere( $tag ) {
998
		$dbw = wfGetDB( DB_MASTER );
999
		$dbw->startAtomic( __METHOD__ );
1000
1001
		// delete from valid_tag
1002
		self::undefineTag( $tag );
1003
1004
		// find out which revisions use this tag, so we can delete from tag_summary
1005
		$result = $dbw->select( 'change_tag',
1006
			[ 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ],
1007
			[ 'ct_tag' => $tag ],
1008
			__METHOD__ );
1009
		foreach ( $result as $row ) {
1010
			// remove the tag from the relevant row of tag_summary
1011
			$tagsToAdd = [];
1012
			$tagsToRemove = [ $tag ];
1013
			self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $row->ct_rc_id,
1014
				$row->ct_rev_id, $row->ct_log_id );
1015
		}
1016
1017
		// delete from change_tag
1018
		$dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
1019
1020
		$dbw->endAtomic( __METHOD__ );
1021
1022
		// give extensions a chance
1023
		$status = Status::newGood();
1024
		Hooks::run( 'ChangeTagAfterDelete', [ $tag, &$status ] );
1025
		// let's not allow error results, as the actual tag deletion succeeded
1026
		if ( !$status->isOK() ) {
1027
			wfDebug( 'ChangeTagAfterDelete error condition downgraded to warning' );
1028
			$status->setOK( true );
1029
		}
1030
1031
		// clear the memcache of defined tags
1032
		self::purgeTagCacheAll();
1033
1034
		return $status;
1035
	}
1036
1037
	/**
1038
	 * Is it OK to allow the user to delete this tag?
1039
	 *
1040
	 * @param string $tag Tag that you are interested in deleting
1041
	 * @param User|null $user User whose permission you wish to check, or null if
1042
	 * you don't care (e.g. maintenance scripts)
1043
	 * @return Status
1044
	 * @since 1.25
1045
	 */
1046
	public static function canDeleteTag( $tag, User $user = null ) {
1047
		$tagUsage = self::tagUsageStatistics();
1048
1049
		if ( !is_null( $user ) ) {
1050
			if ( !$user->isAllowed( 'deletechangetags' ) ) {
1051
				return Status::newFatal( 'tags-delete-no-permission' );
1052
			} elseif ( $user->isBlocked() ) {
1053
				return Status::newFatal( 'tags-manage-blocked' );
1054
			}
1055
		}
1056
1057 View Code Duplication
		if ( !isset( $tagUsage[$tag] ) && !in_array( $tag, self::listDefinedTags() ) ) {
1058
			return Status::newFatal( 'tags-delete-not-found', $tag );
1059
		}
1060
1061
		if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > self::MAX_DELETE_USES ) {
1062
			return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
1063
		}
1064
1065
		$softwareDefined = self::listSoftwareDefinedTags();
1066
		if ( in_array( $tag, $softwareDefined ) ) {
1067
			// extension-defined tags can't be deleted unless the extension
1068
			// specifically allows it
1069
			$status = Status::newFatal( 'tags-delete-not-allowed' );
1070
		} else {
1071
			// user-defined tags are deletable unless otherwise specified
1072
			$status = Status::newGood();
1073
		}
1074
1075
		Hooks::run( 'ChangeTagCanDelete', [ $tag, $user, &$status ] );
1076
		return $status;
1077
	}
1078
1079
	/**
1080
	 * Deletes a tag, checking whether it is allowed first, and adding a log entry
1081
	 * afterwards.
1082
	 *
1083
	 * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
1084
	 * do that.
1085
	 *
1086
	 * @param string $tag
1087
	 * @param string $reason
1088
	 * @param User $user Who to give credit for the action
1089
	 * @param bool $ignoreWarnings Can be used for API interaction, default false
1090
	 * @return Status If successful, the Status contains the ID of the added log
1091
	 * entry as its value
1092
	 * @since 1.25
1093
	 */
1094
	public static function deleteTagWithChecks( $tag, $reason, User $user,
1095
		$ignoreWarnings = false ) {
1096
1097
		// are we allowed to do this?
1098
		$result = self::canDeleteTag( $tag, $user );
1099
		if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
1100
			$result->value = null;
1101
			return $result;
1102
		}
1103
1104
		// store the tag usage statistics
1105
		$tagUsage = self::tagUsageStatistics();
1106
		$hitcount = isset( $tagUsage[$tag] ) ? $tagUsage[$tag] : 0;
1107
1108
		// do it!
1109
		$deleteResult = self::deleteTagEverywhere( $tag );
1110
		if ( !$deleteResult->isOK() ) {
1111
			return $deleteResult;
1112
		}
1113
1114
		// log it
1115
		$logId = self::logTagManagementAction( 'delete', $tag, $reason, $user, $hitcount );
1116
		$deleteResult->value = $logId;
1117
		return $deleteResult;
1118
	}
1119
1120
	/**
1121
	 * Lists those tags which core or extensions report as being "active".
1122
	 *
1123
	 * @return array
1124
	 * @since 1.25
1125
	 */
1126 View Code Duplication
	public static function listSoftwareActivatedTags() {
1127
		// core active tags
1128
		$tags = self::$coreTags;
1129
		if ( !Hooks::isRegistered( 'ChangeTagsListActive' ) ) {
1130
			return $tags;
1131
		}
1132
		return ObjectCache::getMainWANInstance()->getWithSetCallback(
1133
			wfMemcKey( 'active-tags' ),
1134
			WANObjectCache::TTL_MINUTE * 5,
1135
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags ) {
1136
				$setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1137
1138
				// Ask extensions which tags they consider active
1139
				Hooks::run( 'ChangeTagsListActive', [ &$tags ] );
1140
				return $tags;
1141
			},
1142
			[
1143
				'checkKeys' => [ wfMemcKey( 'active-tags' ) ],
1144
				'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1145
				'pcTTL' => WANObjectCache::TTL_PROC_LONG
1146
			]
1147
		);
1148
	}
1149
1150
	/**
1151
	 * @see listSoftwareActivatedTags
1152
	 * @deprecated since 1.28 call listSoftwareActivatedTags directly
1153
	 * @return array
1154
	 */
1155
	public static function listExtensionActivatedTags() {
1156
		wfDeprecated( __METHOD__, '1.28' );
1157
		return self::listSoftwareActivatedTags();
1158
	}
1159
1160
	/**
1161
	 * Basically lists defined tags which count even if they aren't applied to anything.
1162
	 * It returns a union of the results of listExplicitlyDefinedTags() and
1163
	 * listExtensionDefinedTags().
1164
	 *
1165
	 * @return string[] Array of strings: tags
1166
	 */
1167
	public static function listDefinedTags() {
1168
		$tags1 = self::listExplicitlyDefinedTags();
1169
		$tags2 = self::listSoftwareDefinedTags();
1170
		return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
1171
	}
1172
1173
	/**
1174
	 * Lists tags explicitly defined in the `valid_tag` table of the database.
1175
	 * Tags in table 'change_tag' which are not in table 'valid_tag' are not
1176
	 * included.
1177
	 *
1178
	 * Tries memcached first.
1179
	 *
1180
	 * @return string[] Array of strings: tags
1181
	 * @since 1.25
1182
	 */
1183
	public static function listExplicitlyDefinedTags() {
1184
		$fname = __METHOD__;
1185
1186
		return ObjectCache::getMainWANInstance()->getWithSetCallback(
1187
			wfMemcKey( 'valid-tags-db' ),
1188
			WANObjectCache::TTL_MINUTE * 5,
1189
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1190
				$dbr = wfGetDB( DB_REPLICA );
1191
1192
				$setOpts += Database::getCacheSetOptions( $dbr );
1193
1194
				$tags = $dbr->selectFieldValues( 'valid_tag', 'vt_tag', [], $fname );
1195
1196
				return array_filter( array_unique( $tags ) );
1197
			},
1198
			[
1199
				'checkKeys' => [ wfMemcKey( 'valid-tags-db' ) ],
1200
				'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1201
				'pcTTL' => WANObjectCache::TTL_PROC_LONG
1202
			]
1203
		);
1204
	}
1205
1206
	/**
1207
	 * Lists tags defined by core or extensions using the ListDefinedTags hook.
1208
	 * Extensions need only define those tags they deem to be in active use.
1209
	 *
1210
	 * Tries memcached first.
1211
	 *
1212
	 * @return string[] Array of strings: tags
1213
	 * @since 1.25
1214
	 */
1215 View Code Duplication
	public static function listSoftwareDefinedTags() {
1216
		// core defined tags
1217
		$tags = self::$coreTags;
1218
		if ( !Hooks::isRegistered( 'ListDefinedTags' ) ) {
1219
			return $tags;
1220
		}
1221
		return ObjectCache::getMainWANInstance()->getWithSetCallback(
1222
			wfMemcKey( 'valid-tags-hook' ),
1223
			WANObjectCache::TTL_MINUTE * 5,
1224
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags ) {
1225
				$setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
1226
1227
				Hooks::run( 'ListDefinedTags', [ &$tags ] );
1228
				return array_filter( array_unique( $tags ) );
1229
			},
1230
			[
1231
				'checkKeys' => [ wfMemcKey( 'valid-tags-hook' ) ],
1232
				'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1233
				'pcTTL' => WANObjectCache::TTL_PROC_LONG
1234
			]
1235
		);
1236
	}
1237
1238
	/**
1239
	 * Call listSoftwareDefinedTags directly
1240
	 *
1241
	 * @see listSoftwareDefinedTags
1242
	 * @deprecated since 1.28
1243
	 */
1244
	public static function listExtensionDefinedTags() {
1245
		wfDeprecated( __METHOD__, '1.28' );
1246
		return self::listSoftwareDefinedTags();
1247
	}
1248
1249
	/**
1250
	 * Invalidates the short-term cache of defined tags used by the
1251
	 * list*DefinedTags functions, as well as the tag statistics cache.
1252
	 * @since 1.25
1253
	 */
1254
	public static function purgeTagCacheAll() {
1255
		$cache = ObjectCache::getMainWANInstance();
1256
1257
		$cache->touchCheckKey( wfMemcKey( 'active-tags' ) );
1258
		$cache->touchCheckKey( wfMemcKey( 'valid-tags-db' ) );
1259
		$cache->touchCheckKey( wfMemcKey( 'valid-tags-hook' ) );
1260
1261
		self::purgeTagUsageCache();
1262
	}
1263
1264
	/**
1265
	 * Invalidates the tag statistics cache only.
1266
	 * @since 1.25
1267
	 */
1268
	public static function purgeTagUsageCache() {
1269
		$cache = ObjectCache::getMainWANInstance();
1270
1271
		$cache->touchCheckKey( wfMemcKey( 'change-tag-statistics' ) );
1272
	}
1273
1274
	/**
1275
	 * Returns a map of any tags used on the wiki to number of edits
1276
	 * tagged with them, ordered descending by the hitcount.
1277
	 * This does not include tags defined somewhere that have never been applied.
1278
	 *
1279
	 * Keeps a short-term cache in memory, so calling this multiple times in the
1280
	 * same request should be fine.
1281
	 *
1282
	 * @return array Array of string => int
1283
	 */
1284
	public static function tagUsageStatistics() {
1285
		$fname = __METHOD__;
1286
		return ObjectCache::getMainWANInstance()->getWithSetCallback(
1287
			wfMemcKey( 'change-tag-statistics' ),
1288
			WANObjectCache::TTL_MINUTE * 5,
1289
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
1290
				$dbr = wfGetDB( DB_REPLICA, 'vslow' );
1291
1292
				$setOpts += Database::getCacheSetOptions( $dbr );
1293
1294
				$res = $dbr->select(
1295
					'change_tag',
1296
					[ 'ct_tag', 'hitcount' => 'count(*)' ],
1297
					[],
1298
					$fname,
1299
					[ 'GROUP BY' => 'ct_tag', 'ORDER BY' => 'hitcount DESC' ]
1300
				);
1301
1302
				$out = [];
1303
				foreach ( $res as $row ) {
1304
					$out[$row->ct_tag] = $row->hitcount;
1305
				}
1306
1307
				return $out;
1308
			},
1309
			[
1310
				'checkKeys' => [ wfMemcKey( 'change-tag-statistics' ) ],
1311
				'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
1312
				'pcTTL' => WANObjectCache::TTL_PROC_LONG
1313
			]
1314
		);
1315
	}
1316
1317
	/**
1318
	 * Indicate whether change tag editing UI is relevant
1319
	 *
1320
	 * Returns true if the user has the necessary right and there are any
1321
	 * editable tags defined.
1322
	 *
1323
	 * This intentionally doesn't check "any addable || any deletable", because
1324
	 * it seems like it would be more confusing than useful if the checkboxes
1325
	 * suddenly showed up because some abuse filter stopped defining a tag and
1326
	 * then suddenly disappeared when someone deleted all uses of that tag.
1327
	 *
1328
	 * @param User $user
1329
	 * @return bool
1330
	 */
1331
	public static function showTagEditingUI( User $user ) {
1332
		return $user->isAllowed( 'changetags' ) && (bool)self::listExplicitlyDefinedTags();
1333
	}
1334
}
1335