Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.59% covered (danger)
30.59%
93 / 304
12.50% covered (danger)
12.50%
3 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeTags
30.69% covered (danger)
30.69%
93 / 303
12.50% covered (danger)
12.50%
3 / 24
3846.60
0.00% covered (danger)
0.00%
0 / 1
 formatSummaryRow
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 tagShortDescriptionMessage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 tagHelpLink
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 tagDescription
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 tagLongDescriptionMessage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 addTags
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 restrictedTagError
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 canAddTagsAccompanyingChange
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 canUpdateTags
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 canUpdateTagsInternal
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
10.51
 updateTagsWithChecks
21.43% covered (danger)
21.43%
12 / 56
0.00% covered (danger)
0.00%
0 / 1
69.69
 buildTagFilterSelector
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 canActivateTag
35.29% covered (danger)
35.29%
6 / 17
0.00% covered (danger)
0.00%
0 / 1
25.34
 activateTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 canDeactivateTag
40.00% covered (danger)
40.00%
6 / 15
0.00% covered (danger)
0.00%
0 / 1
17.58
 deactivateTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 isTagNameValid
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 canCreateTag
28.57% covered (danger)
28.57%
6 / 21
0.00% covered (danger)
0.00%
0 / 1
31.32
 createTagWithChecks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 canDeleteTag
28.57% covered (danger)
28.57%
8 / 28
0.00% covered (danger)
0.00%
0 / 1
64.48
 deleteTagWithChecks
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getChangeTagListSummary
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getChangeTagList
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 showTagEditingUI
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ChangeTags;
8
9use MediaWiki\Context\IContextSource;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\Language\Language;
13use MediaWiki\Language\MessageLocalizer;
14use MediaWiki\Language\RawMessage;
15use MediaWiki\Language\SimpleLocalizationContext;
16use MediaWiki\Logging\ManualLogEntry;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Message\Message;
19use MediaWiki\Permissions\Authority;
20use MediaWiki\Permissions\PermissionStatus;
21use MediaWiki\Permissions\UltimateAuthority;
22use MediaWiki\RecentChanges\RecentChange;
23use MediaWiki\RevisionDelete\RevDelLogList;
24use MediaWiki\Skin\Skin;
25use MediaWiki\SpecialPage\SpecialPage;
26use MediaWiki\Status\Status;
27use MediaWiki\Title\Title;
28
29/**
30 * @defgroup ChangeTags Change tagging
31 * Tagging for revisions, log entries, or recent changes.
32 *
33 * These can be built-in tags from MediaWiki core, or applied by extensions
34 * via edit filters (e.g. AbuseFilter), or applied by extensions via hooks
35 * (e.g. onRecentChange_save), or manually by authorized users via the
36 * SpecialEditTags interface.
37 *
38 * @see RecentChanges
39 */
40
41/**
42 * Recent changes tagging.
43 *
44 * @ingroup ChangeTags
45 */
46class ChangeTags {
47    /**
48     * The tagged edit changes the content model of the page.
49     */
50    public const TAG_CONTENT_MODEL_CHANGE = 'mw-contentmodelchange';
51    /**
52     * The tagged edit creates a new redirect (either by creating a new page or turning an
53     * existing page into a redirect).
54     */
55    public const TAG_NEW_REDIRECT = 'mw-new-redirect';
56    /**
57     * The tagged edit turns a redirect page into a non-redirect.
58     */
59    public const TAG_REMOVED_REDIRECT = 'mw-removed-redirect';
60    /**
61     * The tagged edit changes the target of a redirect page.
62     */
63    public const TAG_CHANGED_REDIRECT_TARGET = 'mw-changed-redirect-target';
64    /**
65     * The tagged edit blanks the page (replaces it with the empty string).
66     */
67    public const TAG_BLANK = 'mw-blank';
68    /**
69     * The tagged edit removes more than 90% of the content of the page.
70     */
71    public const TAG_REPLACE = 'mw-replace';
72    /**
73     * The tagged edit recreates a page that has been previously deleted.
74     */
75    public const TAG_RECREATE = 'mw-recreated';
76    /**
77     * The tagged edit is a rollback (undoes the previous edit and all immediately preceding edits
78     * by the same user, and was performed via the "rollback" link available to advanced users
79     * or via the rollback API).
80     *
81     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
82     */
83    public const TAG_ROLLBACK = 'mw-rollback';
84    /**
85     * The tagged edit is was performed via the "undo" link. (Usually this means that it undoes
86     * some previous edit, but the undo workflow includes an edit step so it could be anything.)
87     *
88     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
89     */
90    public const TAG_UNDO = 'mw-undo';
91    /**
92     * The tagged edit restores the page to an earlier revision.
93     *
94     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize()).
95     */
96    public const TAG_MANUAL_REVERT = 'mw-manual-revert';
97    /**
98     * The tagged edit is reverted by a subsequent edit (which is tagged by one of TAG_ROLLBACK,
99     * TAG_UNDO, TAG_MANUAL_REVERT). Multiple edits might be reverted by the same edit.
100     *
101     * The associated tag data is a JSON containing the edit result (see EditResult::jsonSerialize())
102     * with an extra 'revertId' field containing the revision ID of the reverting edit.
103     */
104    public const TAG_REVERTED = 'mw-reverted';
105    /**
106     * This tagged edit was performed while importing media files using the importImages.php maintenance script.
107     */
108    public const TAG_SERVER_SIDE_UPLOAD = 'mw-server-side-upload';
109    /**
110     * This tagged temporary account auto-creation was performed via Special:Mytalk
111     * from an IP address that is blocked from account creation.
112     */
113    public const TAG_IPBLOCK_APPEAL = 'mw-ipblock-appeal';
114    /**
115     * This tagged edit was performed on a page with the JavaScript content model on a page
116     * not a subpage of the user's own user page.
117     */
118    public const TAG_EDITED_OTHER_USERS_JS = 'mw-edited-other-users-js';
119    /**
120     * This tagged edit was performed on a page with the CSS content model on a page
121     * not a subpage of the user's own user page.
122     */
123    public const TAG_EDITED_OTHER_USERS_CSS = 'mw-edited-other-users-css';
124
125    /**
126     * List of tags which denote a revert of some sort. (See also TAG_REVERTED.)
127     */
128    public const REVERT_TAGS = [ self::TAG_ROLLBACK, self::TAG_UNDO, self::TAG_MANUAL_REVERT ];
129
130    /**
131     * Flag for canDeleteTag().
132     */
133    public const BYPASS_MAX_USAGE_CHECK = 1;
134
135    /**
136     * Can't delete tags with more than this many uses. Similar in intent to
137     * the bigdelete user right
138     * @todo Use the job queue for tag deletion to avoid this restriction
139     */
140    private const MAX_DELETE_USES = 5000;
141
142    /**
143     * Name of change_tag table
144     */
145    private const CHANGE_TAG = 'change_tag';
146
147    public const DISPLAY_TABLE_ALIAS = 'changetagdisplay';
148
149    /**
150     * Constants that can be used to set the `activeOnly` parameter for calling
151     * self::buildCustomTagFilterSelect in order to improve function/parameter legibility
152     *
153     * If TAG_SET_ACTIVE_ONLY is used then the hit count for each tag will be checked against
154     * and only tags with hits will be returned
155     * Otherwise if TAG_SET_ALL is used then all tags will be returned regardless of if they've
156     * ever been used or not
157     */
158    public const TAG_SET_ACTIVE_ONLY = true;
159    public const TAG_SET_ALL = false;
160
161    /**
162     * Constants that can be used to set the `useAllTags` parameter for calling
163     * self::buildCustomTagFilterSelect in order to improve function/parameter legibility
164     *
165     * If USE_ALL_TAGS is used then all on-wiki tags will be returned
166     * Otherwise if USE_SOFTWARE_TAGS_ONLY is used then only mediawiki core-defined tags
167     * will be returned
168     */
169    public const USE_ALL_TAGS = true;
170    public const USE_SOFTWARE_TAGS_ONLY = false;
171
172    /**
173     * Creates HTML for the given tags
174     *
175     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::formatTagsAsSummaryList} instead.
176     * @param string $tags Comma-separated list of tags
177     * @param null|string $unused Unused (formerly: $page)
178     * @param MessageLocalizer $localizer
179     * @return array Array with two items: (html, classes)
180     *   - html: String: HTML for displaying the tags (empty string when param $tags is empty)
181     *   - classes: Array of strings: CSS classes used in the generated html, one class for each tag
182     * @return-taint onlysafefor_htmlnoent
183     */
184    public static function formatSummaryRow( $tags, $unused, MessageLocalizer $localizer ) {
185        wfDeprecated( __METHOD__, '1.47' );
186        return MediaWikiServices::getInstance()->getChangeTagsFormatter()->formatTagsAsSummaryList(
187            $tags,
188            $localizer,
189            RequestContext::getMain()->getAuthority()
190        );
191    }
192
193    /**
194     * Get the message object for the tag's short description.
195     *
196     * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
197     * returns the tag name in a RawMessage. If the message exists, it is
198     * used, provided it is not disabled. If the message is disabled, we
199     * consider the tag hidden, and return false.
200     *
201     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getTagDescription} instead.
202     * @since 1.34
203     * @param string $tag
204     * @param MessageLocalizer $context
205     * @return Message|false Tag description, or false if tag is to be hidden.
206     */
207    public static function tagShortDescriptionMessage( $tag, MessageLocalizer $context ) {
208        wfDeprecated( __METHOD__, '1.47' );
209        $msg = $context->msg( "tag-$tag" );
210        if ( !$msg->exists() ) {
211            // No such message
212            // Pass through ->msg(), even though it seems redundant, to avoid requesting
213            // the user's language from session-less entry points (T227233)
214            return $context->msg( new RawMessage( '$1', [ Message::plaintextParam( $tag ) ] ) );
215        }
216        if ( $msg->isDisabled() ) {
217            // The message exists but is disabled, hide the tag.
218            return false;
219        }
220
221        // Message exists and isn't disabled, use it.
222        return $msg;
223    }
224
225    /**
226     * Get the tag's help link.
227     *
228     * Checks if message key "mediawiki:tag-$tag-helppage" exists in content language. If it does,
229     * and contains a URL or a page title, return a (possibly relative) link URL that points there.
230     * Otherwise return null.
231     *
232     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getTagDescription} instead.
233     * @since 1.43
234     * @param string $tag
235     * @param MessageLocalizer $context
236     * @return string|null Tag link, or null if not provided or invalid
237     */
238    public static function tagHelpLink( $tag, MessageLocalizer $context ) {
239        wfDeprecated( __METHOD__, '1.47' );
240        $msg = $context->msg( "tag-$tag-helppage" )->inContentLanguage();
241        if ( !$msg->isDisabled() ) {
242            return Skin::makeInternalOrExternalUrl( $msg->text() ) ?: null;
243        }
244        return null;
245    }
246
247    /**
248     * Get a short description for a tag.
249     *
250     * The description combines the label from tagShortDescriptionMessage() with the link from
251     * tagHelpLink() (unless the label already contains some links).
252     *
253     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getTagDescription} instead.
254     * @param string $tag
255     * @param MessageLocalizer $context
256     * @return string|false Tag description or false if tag is to be hidden.
257     * @since 1.25 Returns false if tag is to be hidden.
258     */
259    public static function tagDescription( $tag, MessageLocalizer $context ) {
260        wfDeprecated( __METHOD__, '1.47' );
261        $description = MediaWikiServices::getInstance()->getChangeTagsFormatter()->getTagDescription( $tag, $context );
262        if ( $description === '' ) {
263            return false;
264        }
265        return $description;
266    }
267
268    /**
269     * Get the message object for the tag's long description.
270     *
271     * Checks if message key "mediawiki:tag-$tag-description" exists. If it does not,
272     * or if message is disabled, returns false. Otherwise, returns the message object
273     * for the long description.
274     *
275     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getTagDescription} instead.
276     * @param string $tag
277     * @param MessageLocalizer $context
278     * @return Message|false Message object of the tag long description or false if
279     *  there is no description.
280     */
281    public static function tagLongDescriptionMessage( $tag, MessageLocalizer $context ) {
282        wfDeprecated( __METHOD__, '1.47' );
283        $msg = $context->msg( "tag-$tag-description" );
284        return $msg->isDisabled() ? false : $msg;
285    }
286
287    /**
288     * Add tags to a change given its rc_id, rev_id and/or log_id
289     *
290     * @deprecated since 1.41 use ChangeTagsStore instead. Hard-deprecated since 1.44.
291     * @param string|string[] $tags Tags to add to the change
292     * @param int|null $rc_id The rc_id of the change to add the tags to
293     * @param int|null $rev_id The rev_id of the change to add the tags to
294     * @param int|null $log_id The log_id of the change to add the tags to
295     * @param string|null $params Params to put in the ct_params field of table 'change_tag'
296     * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
297     * (this should normally be the case)
298     *
299     * @return bool False if no changes are made, otherwise true
300     */
301    public static function addTags( $tags, $rc_id = null, $rev_id = null,
302        $log_id = null, $params = null, ?RecentChange $rc = null
303    ) {
304        wfDeprecated( __METHOD__, '1.41' );
305        return MediaWikiServices::getInstance()->getChangeTagsStore()->addTags(
306            $tags, $rc_id, $rev_id, $log_id, $params, $rc
307        );
308    }
309
310    /**
311     * Helper function to generate a fatal status with a 'not-allowed' type error.
312     *
313     * @param string $msgOne Message key to use in the case of one tag
314     * @param string $msgMulti Message key to use in the case of more than one tag
315     * @param string[] $tags Restricted tags (passed as $1 into the message, count of
316     * $tags passed as $2)
317     * @return Status
318     * @since 1.25
319     */
320    protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
321        $tags = array_values( $tags );
322        $count = count( $tags );
323        $status = Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
324            Message::listParam( $tags ), $count );
325        $status->value = $tags;
326        return $status;
327    }
328
329    /**
330     * Is it OK to allow the user to apply all the specified tags at the same time
331     * as they edit/make the change?
332     *
333     * Extensions should not use this function, unless directly handling a user
334     * request to add a tag to a revision or log entry that the user is making.
335     *
336     * @param string[] $tags Tags that you are interested in applying
337     * @param Authority|null $performer whose permission you wish to check, or null to
338     * check for a generic non-blocked user with the relevant rights
339     * @param bool $checkBlock Whether to check the blocked status of $performer
340     * @return Status
341     * @since 1.25
342     */
343    public static function canAddTagsAccompanyingChange(
344        array $tags,
345        ?Authority $performer = null,
346        $checkBlock = true
347    ) {
348        $user = null;
349        $services = MediaWikiServices::getInstance();
350        if ( $performer !== null ) {
351            if ( !$performer->isAllowed( 'applychangetags' ) ) {
352                return Status::newFatal( 'tags-apply-no-permission' );
353            }
354
355            if ( $checkBlock && $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
356                return Status::newFatal(
357                    'tags-apply-blocked',
358                    $performer->getUser()->getName()
359                );
360            }
361
362            // ChangeTagsAllowedAdd hook still needs a full User object
363            $user = $services->getUserFactory()->newFromAuthority( $performer );
364        }
365
366        // to be applied, a tag has to be explicitly defined
367        $allowedTags = $services->getChangeTagsStore()->listExplicitlyDefinedTags();
368        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagsAllowedAdd( $allowedTags, $tags, $user );
369        $disallowedTags = array_diff( $tags, $allowedTags );
370        if ( $disallowedTags ) {
371            return self::restrictedTagError( 'tags-apply-not-allowed-one',
372                'tags-apply-not-allowed-multi', $disallowedTags );
373        }
374
375        return Status::newGood();
376    }
377
378    /**
379     * Is it OK to allow the user to adds and remove the given tags to/from a
380     * change?
381     *
382     * Extensions should not use this function, unless directly handling a user
383     * request to add or remove tags from an existing revision or log entry.
384     *
385     * @deprecated Since 1.47
386     * @param string[] $tagsToAdd Tags that you are interested in adding
387     * @param string[] $tagsToRemove Tags that you are interested in removing
388     * @param Authority|null $performer whose permission you wish to check, or null to
389     * check for a generic non-blocked user with the relevant rights
390     * @return Status
391     * @since 1.25
392     */
393    public static function canUpdateTags(
394        array $tagsToAdd,
395        array $tagsToRemove,
396        ?Authority $performer = null
397    ) {
398        wfDeprecated( __METHOD__, '1.47' );
399        return self::canUpdateTagsInternal(
400            $tagsToAdd,
401            $tagsToRemove,
402            $performer ?? new UltimateAuthority( RequestContext::getMain()->getUser() )
403        );
404    }
405
406    /**
407     * Decides whether the given {@link Authority} can add and remove the given tags
408     * to/from a change.
409     *
410     * NOTE: The method is named with "internal" so that the existing public version
411     * of this method can be still exist but be deprecated. Once {@link self::canUpdateTags()}
412     * is dropped, this method should be renamed to remove "internal"
413     *
414     * @param string[] $tagsToAdd Tags that are being added
415     * @param string[] $tagsToRemove Tags that are being removed
416     * @param Authority $performer
417     * @return Status
418     */
419    private static function canUpdateTagsInternal(
420        array $tagsToAdd,
421        array $tagsToRemove,
422        Authority $performer
423    ): Status {
424        if ( !$performer->isDefinitelyAllowed( 'changetags' ) ) {
425            return Status::newFatal( 'tags-update-no-permission' );
426        }
427
428        if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
429            return Status::newFatal(
430                'tags-update-blocked',
431                $performer->getUser()->getName()
432            );
433        }
434
435        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
436        if ( $tagsToAdd ) {
437            // to be added, a tag has to be explicitly defined
438            // @todo Allow extensions to define tags that can be applied by users...
439            $explicitlyDefinedTags = $changeTagsStore->listExplicitlyDefinedTags();
440            $diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
441            if ( $diff ) {
442                return self::restrictedTagError( 'tags-update-add-not-allowed-one',
443                    'tags-update-add-not-allowed-multi', $diff );
444            }
445        }
446
447        if ( $tagsToRemove ) {
448            // Restricted tags can only be removed by users who can view the tag. We do this
449            // separately so that private tags that are undefined cannot be removed from changes.
450            $unviewableRestricted = array_filter(
451                $tagsToRemove,
452                static fn ( $tag ) => $changeTagsStore->isRestrictedTag( $tag )
453                    && !$changeTagsStore->canViewTag( $tag, $performer )
454            );
455            if ( $unviewableRestricted ) {
456                return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
457                    'tags-update-remove-not-allowed-multi', $unviewableRestricted );
458            }
459
460            // to be removed, a tag must not be defined by an extension, or equivalently it
461            // has to be either explicitly defined or not defined at all
462            // (assuming no edge case of a tag both explicitly-defined and extension-defined)
463            $softwareDefinedTags = $changeTagsStore->listSoftwareDefinedTags();
464            $intersect = array_intersect( $tagsToRemove, $softwareDefinedTags );
465            if ( $intersect ) {
466                return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
467                    'tags-update-remove-not-allowed-multi', $intersect );
468            }
469        }
470
471        return Status::newGood();
472    }
473
474    /**
475     * Adds and/or removes tags to/from a given change, checking whether it is
476     * allowed first, and adding a log entry afterwards.
477     *
478     * This validates that the provided {@link Authority} can make the changes, but
479     * it does not check whether the *_id parameters are a valid combination.
480     * That is up to you to enforce. See ApiTag::execute() for an example.
481     *
482     * Extensions should generally avoid this function. Call
483     * ChangeTagsStore->updateTags() instead, unless directly handling a user request
484     * to add or remove tags from an existing revision or log entry.
485     *
486     * @param array|null $tagsToAdd If none, pass [] or null
487     * @param array|null $tagsToRemove If none, pass [] or null
488     * @param int|null $rc_id The rc_id of the change to add the tags to
489     * @param int|null $rev_id The rev_id of the change to add the tags to
490     * @param int|null $log_id The log_id of the change to add the tags to
491     * @param string|null $params Params to put in the ct_params field of table
492     * 'change_tag' when adding tags
493     * @param string $reason Comment for the log
494     * @param Authority $performer who to check permissions and give credit for the action
495     * @return Status If successful, the value of this Status object will be an
496     * object (stdClass) with the following fields:
497     *  - logId: the ID of the added log entry, or null if no log entry was added
498     *    (i.e. no operation was performed)
499     *  - addedTags: an array containing the tags that were actually added
500     *  - removedTags: an array containing the tags that were actually removed
501     * @since 1.25
502     */
503    public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
504        $rc_id, $rev_id, $log_id, $params, string $reason, Authority $performer
505    ) {
506        if ( !$tagsToAdd && !$tagsToRemove ) {
507            // no-op, don't bother
508            return Status::newGood( (object)[
509                'logId' => null,
510                'addedTags' => [],
511                'removedTags' => [],
512            ] );
513        }
514
515        $tagsToAdd ??= [];
516        $tagsToRemove ??= [];
517
518        // are we allowed to do this?
519        $result = self::canUpdateTagsInternal( $tagsToAdd, $tagsToRemove, $performer );
520        if ( !$result->isOK() ) {
521            $result->value = null;
522            return $result;
523        }
524
525        // basic rate limiting
526        $status = PermissionStatus::newEmpty();
527        if ( !$performer->authorizeAction( 'changetags', $status ) ) {
528            return Status::wrap( $status );
529        }
530
531        // do it!
532        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
533        [ $tagsAdded, $tagsRemoved, $initialTags ] = $changeTagsStore->updateTags( $tagsToAdd,
534            $tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $performer->getUser() );
535        if ( !$tagsAdded && !$tagsRemoved ) {
536            // no-op, don't log it
537            return Status::newGood( (object)[
538                'logId' => null,
539                'addedTags' => [],
540                'removedTags' => [],
541            ] );
542        }
543
544        // log it
545        $logEntry = new ManualLogEntry( 'tag', 'update' );
546        $logEntry->setPerformer( $performer->getUser() );
547        $logEntry->setComment( $reason );
548
549        // find the appropriate target page
550        if ( $rev_id ) {
551            $revisionRecord = MediaWikiServices::getInstance()
552                ->getRevisionLookup()
553                ->getRevisionById( $rev_id );
554            if ( $revisionRecord ) {
555                $logEntry->setTarget( $revisionRecord->getPageAsLinkTarget() );
556            }
557        } elseif ( $log_id ) {
558            // This function is from revision deletion logic and has nothing to do with
559            // change tags, but it appears to be the only other place in core where we
560            // perform logged actions on log items.
561            $logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
562        }
563
564        if ( !$logEntry->getTarget() ) {
565            // target is required, so we have to set something
566            $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
567        }
568
569        $logParams = [
570            '4::revid' => $rev_id,
571            '5::logid' => $log_id,
572            '6:list:tagsAdded' => $tagsAdded,
573            '7:number:tagsAddedCount' => count( $tagsAdded ),
574            '8:list:tagsRemoved' => $tagsRemoved,
575            '9:number:tagsRemovedCount' => count( $tagsRemoved ),
576            'initialTags' => $initialTags,
577        ];
578        $logEntry->setParameters( $logParams );
579        $logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
580
581        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
582        $logId = $logEntry->insert( $dbw );
583        // Only send this to UDP, not RC, similar to patrol events
584        $logEntry->publish( $logId, 'udp' );
585
586        return Status::newGood( (object)[
587            'logId' => $logId,
588            'addedTags' => $tagsAdded,
589            'removedTags' => $tagsRemoved,
590        ] );
591    }
592
593    /**
594     * Build a text box to select a change tag. The tag set can be customized via the $activeOnly
595     * and $useAllTags parameters and defaults to all active tags.
596     *
597     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::buildTagFilter()} instead.
598     * @param string $selected Tag to select by default
599     * @param bool $ooui Use an OOUI TextInputWidget as selector instead of a non-OOUI input field
600     *        You need to call OutputPage::enableOOUI() yourself.
601     * @param IContextSource $context
602     * @param bool $activeOnly Whether to filter for tags that have been used or not
603     * @param bool $useAllTags Whether to use all known tags or to only use software defined tags
604     *        These map to ChangeTagsStore->listDefinedTags and ChangeTagsStore->getCoreDefinedTags respectively
605     * @return array{0:string,1:string}|null Two chunks of HTML (label, and dropdown menu) or null if disabled
606     */
607    public static function buildTagFilterSelector(
608        $selected, $ooui, IContextSource $context,
609        bool $activeOnly = self::TAG_SET_ACTIVE_ONLY,
610        bool $useAllTags = self::USE_ALL_TAGS
611    ) {
612        wfDeprecated( __METHOD__, '1.47' );
613        return MediaWikiServices::getInstance()->getChangeTagsFormatter()->buildTagFilter(
614            $selected,
615            $ooui ? 'ooui' : 'other',
616            $context,
617            $activeOnly,
618            $useAllTags
619        );
620    }
621
622    /**
623     * Is it OK to allow the user to activate this tag?
624     *
625     * @param string $tag Tag that you are interested in activating
626     * @param Authority|null $performer whose permission you wish to check, or null if
627     * you don't care (e.g. maintenance scripts)
628     * @return Status
629     * @since 1.25
630     */
631    public static function canActivateTag( string $tag, ?Authority $performer = null ) {
632        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
633        if ( $performer !== null ) {
634            if ( !$performer->isAllowed( 'managechangetags' ) ) {
635                return Status::newFatal( 'tags-manage-no-permission' );
636            }
637            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
638                return Status::newFatal(
639                    'tags-manage-blocked',
640                    $performer->getUser()->getName()
641                );
642            }
643            if ( $changeTagsStore->filterViewableTags( [ $tag ], $performer ) === [] ) {
644                return Status::newFatal( 'tags-activate-not-found', $tag );
645            }
646        }
647
648        // defined tags cannot be activated (a defined tag is either extension-
649        // defined, in which case the extension chooses whether or not to active it;
650        // or user-defined, in which case it is considered active)
651        $definedTags = $changeTagsStore->listDefinedTags();
652        if ( in_array( $tag, $definedTags ) ) {
653            return Status::newFatal( 'tags-activate-not-allowed', $tag );
654        }
655
656        // non-existing tags cannot be activated
657        if ( !isset( $changeTagsStore->tagUsageStatistics()[$tag] ) ) { // we already know the tag is undefined
658            return Status::newFatal( 'tags-activate-not-found', $tag );
659        }
660
661        return Status::newGood();
662    }
663
664    /**
665     * Activates a tag, checking whether it is allowed first, and adding a log
666     * entry afterwards.
667     *
668     * Includes a call to ChangeTag::canActivateTag(), so your code doesn't need
669     * to do that.
670     *
671     * @param string $tag
672     * @param string $reason
673     * @param Authority $performer who to check permissions and give credit for the action
674     * @param bool $ignoreWarnings Can be used for API interaction, default false
675     * @param array $logEntryTags Change tags to apply to the entry
676     * that will be created in the tag management log
677     * @return Status If successful, the Status contains the ID of the added log
678     * entry as its value
679     * @since 1.25
680     */
681    public static function activateTagWithChecks( string $tag, string $reason, Authority $performer,
682        bool $ignoreWarnings = false, array $logEntryTags = []
683    ) {
684        // are we allowed to do this?
685        $result = self::canActivateTag( $tag, $performer );
686        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
687            $result->value = null;
688            return $result;
689        }
690        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
691
692        $changeTagsStore->defineTag( $tag );
693
694        $logId = $changeTagsStore->logTagManagementAction( 'activate', $tag, $reason, $performer->getUser(),
695            null, $logEntryTags );
696
697        return Status::newGood( $logId );
698    }
699
700    /**
701     * Is it OK to allow the user to deactivate this tag?
702     *
703     * @param string $tag Tag that you are interested in deactivating
704     * @param Authority|null $performer whose permission you wish to check, or null if
705     * you don't care (e.g. maintenance scripts)
706     * @return Status
707     * @since 1.25
708     */
709    public static function canDeactivateTag( string $tag, ?Authority $performer = null ) {
710        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
711        if ( $performer !== null ) {
712            if ( !$performer->isAllowed( 'managechangetags' ) ) {
713                return Status::newFatal( 'tags-manage-no-permission' );
714            }
715            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
716                return Status::newFatal(
717                    'tags-manage-blocked',
718                    $performer->getUser()->getName()
719                );
720            }
721            if ( $changeTagsStore->filterViewableTags( [ $tag ], $performer ) === [] ) {
722                return Status::newFatal( 'tags-deactivate-not-found', $tag );
723            }
724        }
725
726        // only explicitly-defined tags can be deactivated
727        $explicitlyDefinedTags = $changeTagsStore->listExplicitlyDefinedTags();
728        if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
729            return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
730        }
731        return Status::newGood();
732    }
733
734    /**
735     * Deactivates a tag, checking whether it is allowed first, and adding a log
736     * entry afterwards.
737     *
738     * Includes a call to ChangeTag::canDeactivateTag(), so your code doesn't need
739     * to do that.
740     *
741     * @param string $tag
742     * @param string $reason
743     * @param Authority $performer who to check permissions and give credit for the action
744     * @param bool $ignoreWarnings Can be used for API interaction, default false
745     * @param array $logEntryTags Change tags to apply to the entry
746     * that will be created in the tag management log
747     * @return Status If successful, the Status contains the ID of the added log
748     * entry as its value
749     * @since 1.25
750     */
751    public static function deactivateTagWithChecks( string $tag, string $reason, Authority $performer,
752        bool $ignoreWarnings = false, array $logEntryTags = []
753    ) {
754        // are we allowed to do this?
755        $result = self::canDeactivateTag( $tag, $performer );
756        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
757            $result->value = null;
758            return $result;
759        }
760        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
761
762        $changeTagsStore->undefineTag( $tag );
763
764        $logId = $changeTagsStore->logTagManagementAction( 'deactivate', $tag, $reason,
765            $performer->getUser(), null, $logEntryTags );
766
767        return Status::newGood( $logId );
768    }
769
770    /**
771     * Is the tag name valid?
772     *
773     * @param string $tag Tag that you are interested in creating
774     * @return Status
775     * @since 1.30
776     */
777    public static function isTagNameValid( $tag ) {
778        // no empty tags
779        if ( $tag === '' ) {
780            return Status::newFatal( 'tags-create-no-name' );
781        }
782
783        // tags cannot contain commas (used to be used as a delimiter in tag_summary table),
784        // pipe (used as a delimiter between multiple tags in
785        // SpecialRecentchanges and friends), or slashes (would break tag description messages in
786        // MediaWiki namespace)
787        if ( str_contains( $tag, ',' ) || str_contains( $tag, '|' ) || str_contains( $tag, '/' ) ) {
788            return Status::newFatal( 'tags-create-invalid-chars' );
789        }
790
791        // could the MediaWiki namespace description messages be created?
792        $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
793        if ( $title === null ) {
794            return Status::newFatal( 'tags-create-invalid-title-chars' );
795        }
796
797        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
798        if ( $changeTagsStore->isRestrictedTag( $tag ) ) {
799            return Status::newFatal( 'tags-create-reserved-prefix', ChangeTagsStore::PRIVATE_TAG_PREFIX );
800        }
801
802        return Status::newGood();
803    }
804
805    /**
806     * Is it OK to allow the user to create this tag?
807     *
808     * Extensions should NOT use this function. In most cases, a tag can be
809     * defined using the ListDefinedTags hook without any checking.
810     *
811     * @param string $tag Tag that you are interested in creating
812     * @param Authority|null $performer whose permission you wish to check, or null if
813     * you don't care (e.g. maintenance scripts)
814     * @return Status
815     * @since 1.25
816     */
817    public static function canCreateTag( $tag, ?Authority $performer = null ) {
818        $user = null;
819        $services = MediaWikiServices::getInstance();
820        if ( $performer !== null ) {
821            if ( !$performer->isAllowed( 'managechangetags' ) ) {
822                return Status::newFatal( 'tags-manage-no-permission' );
823            }
824            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
825                return Status::newFatal(
826                    'tags-manage-blocked',
827                    $performer->getUser()->getName()
828                );
829            }
830            // ChangeTagCanCreate hook still needs a full User object
831            $user = $services->getUserFactory()->newFromAuthority( $performer );
832        }
833
834        $status = self::isTagNameValid( $tag );
835        if ( !$status->isGood() ) {
836            return $status;
837        }
838
839        // does the tag already exist?
840        $changeTagsStore = $services->getChangeTagsStore();
841        if (
842            isset( $changeTagsStore->tagUsageStatistics()[$tag] ) ||
843            in_array( $tag, $changeTagsStore->listDefinedTags() )
844        ) {
845            return Status::newFatal( 'tags-create-already-exists', $tag );
846        }
847
848        // check with hooks
849        $canCreateResult = Status::newGood();
850        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagCanCreate( $tag, $user, $canCreateResult );
851        return $canCreateResult;
852    }
853
854    /**
855     * Creates a tag by adding it to `change_tag_def` table.
856     *
857     * Extensions should NOT use this function; they can use the ListDefinedTags
858     * hook instead.
859     *
860     * Includes a call to ChangeTag::canCreateTag(), so your code doesn't need to
861     * do that.
862     *
863     * @param string $tag
864     * @param string $reason
865     * @param Authority $performer who to check permissions and give credit for the action
866     * @param bool $ignoreWarnings Can be used for API interaction, default false
867     * @param array $logEntryTags Change tags to apply to the entry
868     * that will be created in the tag management log
869     * @return Status If successful, the Status contains the ID of the added log
870     * entry as its value
871     * @since 1.25
872     */
873    public static function createTagWithChecks( string $tag, string $reason, Authority $performer,
874        bool $ignoreWarnings = false, array $logEntryTags = []
875    ) {
876        // are we allowed to do this?
877        $result = self::canCreateTag( $tag, $performer );
878        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
879            $result->value = null;
880            return $result;
881        }
882
883        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
884        $changeTagsStore->defineTag( $tag );
885        $logId = $changeTagsStore->logTagManagementAction( 'create', $tag, $reason,
886            $performer->getUser(), null, $logEntryTags );
887
888        return Status::newGood( $logId );
889    }
890
891    /**
892     * Is it OK to allow the user to delete this tag?
893     *
894     * @param string $tag Tag that you are interested in deleting
895     * @param Authority|null $performer whose permission you wish to check, or null if
896     * you don't care (e.g. maintenance scripts)
897     * @param int $flags Use ChangeTags::BYPASS_MAX_USAGE_CHECK to ignore whether
898     *  there are more uses than we would normally allow to be deleted through the
899     *  user interface.
900     * @return Status
901     * @since 1.25
902     */
903    public static function canDeleteTag( string $tag, ?Authority $performer = null, int $flags = 0 ) {
904        $user = null;
905        $services = MediaWikiServices::getInstance();
906        $changeTagsStore = $services->getChangeTagsStore();
907        if ( $performer !== null ) {
908            if ( !$performer->isAllowed( 'deletechangetags' ) ) {
909                return Status::newFatal( 'tags-delete-no-permission' );
910            }
911            if ( $performer->getBlock() && $performer->getBlock()->isSitewide() ) {
912                return Status::newFatal(
913                    'tags-manage-blocked',
914                    $performer->getUser()->getName()
915                );
916            }
917            if ( $changeTagsStore->filterViewableTags( [ $tag ], $performer ) === [] ) {
918                return Status::newFatal( 'tags-delete-not-found', $tag );
919            }
920            // ChangeTagCanDelete hook still needs a full User object
921            $user = $services->getUserFactory()->newFromAuthority( $performer );
922        }
923
924        $tagUsage = $changeTagsStore->tagUsageStatistics();
925        if (
926            !isset( $tagUsage[$tag] ) &&
927            !in_array( $tag, $changeTagsStore->listDefinedTags() )
928        ) {
929            return Status::newFatal( 'tags-delete-not-found', $tag );
930        }
931
932        if ( $flags !== self::BYPASS_MAX_USAGE_CHECK &&
933            isset( $tagUsage[$tag] ) &&
934            $tagUsage[$tag] > self::MAX_DELETE_USES
935        ) {
936            return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
937        }
938
939        $softwareDefined = $changeTagsStore->listSoftwareDefinedTags();
940        if ( in_array( $tag, $softwareDefined ) ) {
941            // extension-defined tags can't be deleted unless the extension
942            // specifically allows it
943            $status = Status::newFatal( 'tags-delete-not-allowed' );
944        } else {
945            // user-defined tags are deletable unless otherwise specified
946            $status = Status::newGood();
947        }
948
949        ( new HookRunner( $services->getHookContainer() ) )->onChangeTagCanDelete( $tag, $user, $status );
950        return $status;
951    }
952
953    /**
954     * Deletes a tag, checking whether it is allowed first, and adding a log entry
955     * afterwards.
956     *
957     * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
958     * do that.
959     *
960     * @param string $tag
961     * @param string $reason
962     * @param Authority $performer who to check permissions and give credit for the action
963     * @param bool $ignoreWarnings Can be used for API interaction, default false
964     * @param array $logEntryTags Change tags to apply to the entry
965     * that will be created in the tag management log
966     * @return Status If successful, the Status contains the ID of the added log
967     * entry as its value
968     * @since 1.25
969     */
970    public static function deleteTagWithChecks( string $tag, string $reason, Authority $performer,
971        bool $ignoreWarnings = false, array $logEntryTags = []
972    ) {
973        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
974        // are we allowed to do this?
975        $result = self::canDeleteTag( $tag, $performer );
976        if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
977            $result->value = null;
978            return $result;
979        }
980
981        // store the tag usage statistics
982        $hitcount = $changeTagsStore->tagUsageStatistics()[$tag] ?? 0;
983
984        // do it!
985        $deleteResult = $changeTagsStore->deleteTagEverywhere( $tag );
986        if ( !$deleteResult->isOK() ) {
987            return $deleteResult;
988        }
989
990        // log it
991        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
992        $logId = $changeTagsStore->logTagManagementAction( 'delete', $tag, $reason, $performer->getUser(),
993            $hitcount, $logEntryTags );
994
995        $deleteResult->value = $logId;
996        return $deleteResult;
997    }
998
999    /**
1000     * Get information about change tags, without parsing messages, for tag filter dropdown menus.
1001     * By default, this will return explicitly-defined and software-defined tags that are currently active (have hits)
1002     *
1003     * Message contents are the raw values (->plain()), because parsing messages is expensive.
1004     * Even though we're not parsing messages, building a data structure with the contents of
1005     * hundreds of i18n messages is still not cheap (see T223260#5370610), so this function
1006     * caches its output in WANCache for up to 24 hours.
1007     *
1008     * Returns an array of associative arrays with information about each tag:
1009     * - name: Tag name (string)
1010     * - labelMsg: Short description message (Message object, or false for hidden tags)
1011     * - label: Short description message (raw message contents)
1012     * - descriptionMsg: Long description message (Message object)
1013     * - description: Long description message (raw message contents)
1014     * - cssClass: CSS class to use for RC entries with this tag
1015     * - helpLink: Link to a help page describing this tag (string or null)
1016     * - hits: Number of RC entries that have this tag
1017     *
1018     * This data is consumed by the `mediawiki.rcfilters.filters.ui` module,
1019     * specifically `mw.rcfilters.dm.FilterGroup` and `mw.rcfilters.dm.FilterItem`.
1020     *
1021     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getChangeTagListSummary()} instead.
1022     * @param MessageLocalizer $localizer
1023     * @param Language $lang
1024     * @param bool $activeOnly
1025     * @param bool $useAllTags
1026     * @return array[] Information about each tag
1027     */
1028    public static function getChangeTagListSummary(
1029        MessageLocalizer $localizer,
1030        Language $lang,
1031        bool $activeOnly = self::TAG_SET_ACTIVE_ONLY,
1032        bool $useAllTags = self::USE_ALL_TAGS
1033    ) {
1034        wfDeprecated( __METHOD__, '1.47' );
1035        return MediaWikiServices::getInstance()->getChangeTagsFormatter()->getChangeTagListSummary(
1036            new SimpleLocalizationContext( $localizer, $lang ),
1037            ( $localizer instanceof IContextSource ? $localizer : RequestContext::getMain() )->getAuthority(),
1038            $activeOnly,
1039            $useAllTags
1040        );
1041    }
1042
1043    /**
1044     * Get information about change tags for tag filter dropdown menus.
1045     *
1046     * This manipulates the label and description of each tag, which are parsed, stripped
1047     * and (in the case of description) truncated versions of these messages. Message
1048     * parsing is expensive, so to detect whether the tag list has changed, use
1049     * getChangeTagListSummary() instead.
1050     *
1051     * @deprecated Since 1.47. Use {@link ChangeTagsFormatter::getChangeTagList()} instead.
1052     * @param MessageLocalizer $localizer
1053     * @param Language $lang
1054     * @param bool $activeOnly
1055     * @param bool $useAllTags
1056     * @param bool $labelsOnly Do not parse descriptions and omit 'description' in the result
1057     * @return array[] Same as getChangeTagListSummary(), with messages parsed, stripped and truncated
1058     */
1059    public static function getChangeTagList(
1060        MessageLocalizer $localizer, Language $lang,
1061        bool $activeOnly = self::TAG_SET_ACTIVE_ONLY, bool $useAllTags = self::USE_ALL_TAGS,
1062        $labelsOnly = false
1063    ) {
1064        wfDeprecated( __METHOD__, '1.47' );
1065        return MediaWikiServices::getInstance()->getChangeTagsFormatter()->getChangeTagList(
1066            new SimpleLocalizationContext( $localizer, $lang ),
1067            ( $localizer instanceof IContextSource ? $localizer : RequestContext::getMain() )->getAuthority(),
1068            $activeOnly,
1069            $useAllTags,
1070            $labelsOnly
1071        );
1072    }
1073
1074    /**
1075     * Indicate whether change tag editing UI is relevant
1076     *
1077     * Returns true if the user has the necessary right and there are any
1078     * editable tags defined.
1079     *
1080     * This intentionally doesn't check "any addable || any deletable", because
1081     * it seems like it would be more confusing than useful if the checkboxes
1082     * suddenly showed up because some abuse filter stopped defining a tag and
1083     * then suddenly disappeared when someone deleted all uses of that tag.
1084     *
1085     * @param Authority $performer
1086     * @return bool
1087     */
1088    public static function showTagEditingUI( Authority $performer ) {
1089        $changeTagsStore = MediaWikiServices::getInstance()->getChangeTagsStore();
1090        return $performer->isAllowed( 'changetags' ) && (bool)$changeTagsStore->listExplicitlyDefinedTags();
1091    }
1092}
1093
1094/** @deprecated class alias since 1.44 */
1095class_alias( ChangeTags::class, 'ChangeTags' );