Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.42% covered (warning)
59.42%
350 / 589
27.78% covered (danger)
27.78%
10 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecentChange
59.42% covered (warning)
59.42%
350 / 589
27.78% covered (danger)
27.78%
10 / 36
1695.60
0.00% covered (danger)
0.00%
0 / 1
 newFromRow
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseToRCType
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 parseFromRCType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getChangeTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromConds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
1.01
 setAttribs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExtra
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getPage
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 getPerformerIdentity
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 save
75.61% covered (warning)
75.61%
62 / 82
0.00% covered (danger)
0.00%
0 / 1
21.19
 notifyRCFeeds
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
13
 doMarkPatrolled
83.78% covered (warning)
83.78%
31 / 37
0.00% covered (danger)
0.00%
0 / 1
17.09
 reallyMarkPatrolled
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 notifyEdit
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
12
 notifyNew
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
3
 notifyLog
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 newLogEntry
79.41% covered (warning)
79.41%
54 / 68
0.00% covered (danger)
0.00%
0 / 1
18.23
 newForCategorization
95.83% covered (success)
95.83%
46 / 48
0.00% covered (danger)
0.00%
0 / 1
4
 getParam
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadFromRow
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 getAttribute
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
12.10
 getAttributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 diffLinkTrail
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getCharacterDifference
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 checkIPAddress
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 isInRCLifespan
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isEnotifEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getNotifyUrl
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 parseParams
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addTags
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setEditResult
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserIdentityFromAnyId
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
156
1<?php
2/**
3 * Utility class for creating and accessing recent change entries.
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 */
22
23use MediaWiki\ChangeTags\Taggable;
24use MediaWiki\Config\Config;
25use MediaWiki\Deferred\DeferredUpdates;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Page\PageIdentity;
30use MediaWiki\Page\PageReference;
31use MediaWiki\Page\PageReferenceValue;
32use MediaWiki\Permissions\Authority;
33use MediaWiki\Permissions\PermissionStatus;
34use MediaWiki\Storage\EditResult;
35use MediaWiki\Title\Title;
36use MediaWiki\User\UserIdentity;
37use MediaWiki\User\UserIdentityValue;
38use MediaWiki\Utils\MWTimestamp;
39use Wikimedia\Assert\Assert;
40use Wikimedia\AtEase\AtEase;
41use Wikimedia\IPUtils;
42
43/**
44 * Utility class for creating new RC entries
45 *
46 * mAttribs:
47 *  rc_id           id of the row in the recentchanges table
48 *  rc_timestamp    time the entry was made
49 *  rc_namespace    namespace #
50 *  rc_title        non-prefixed db key
51 *  rc_type         is new entry, used to determine whether updating is necessary
52 *  rc_source       string representation of change source
53 *  rc_minor        is minor
54 *  rc_cur_id       page_id of associated page entry
55 *  rc_user         user id who made the entry
56 *  rc_user_text    user name who made the entry
57 *  rc_comment      edit summary
58 *  rc_this_oldid   rev_id associated with this entry (or zero)
59 *  rc_last_oldid   rev_id associated with the entry before this one (or zero)
60 *  rc_bot          is bot, hidden
61 *  rc_ip           IP address of the user in dotted quad notation
62 *  rc_new          obsolete, use rc_type==RC_NEW
63 *  rc_patrolled    boolean whether or not someone has marked this edit as patrolled
64 *  rc_old_len      integer byte length of the text before the edit
65 *  rc_new_len      the same after the edit
66 *  rc_deleted      partial deletion
67 *  rc_logid        the log_id value for this log entry (or zero)
68 *  rc_log_type     the log type (or null)
69 *  rc_log_action   the log action (or null)
70 *  rc_params       log params
71 *
72 * mExtra:
73 *  prefixedDBkey   prefixed db key, used by external app via msg queue
74 *  lastTimestamp   timestamp of previous entry, used in WHERE clause during update
75 *  oldSize         text size before the change
76 *  newSize         text size after the change
77 *  pageStatus      status of the page: created, deleted, moved, restored, changed
78 *
79 * temporary:       not stored in the database
80 *      notificationtimestamp
81 *      numberofWatchingusers
82 *      watchlistExpiry        for temporary watchlist items
83 *
84 * @todo Deprecate access to mAttribs (direct or via getAttributes). Right now
85 *  we're having to include both rc_comment and rc_comment_text/rc_comment_data
86 *  so random crap works right.
87 */
88class RecentChange implements Taggable {
89    use DeprecationHelper;
90
91    // Constants for the rc_source field.  Extensions may also have
92    // their own source constants.
93    public const SRC_EDIT = 'mw.edit';
94    public const SRC_NEW = 'mw.new';
95    public const SRC_LOG = 'mw.log';
96    public const SRC_EXTERNAL = 'mw.external'; // obsolete
97    public const SRC_CATEGORIZE = 'mw.categorize';
98
99    public const PRC_UNPATROLLED = 0;
100    public const PRC_PATROLLED = 1;
101    public const PRC_AUTOPATROLLED = 2;
102
103    /**
104     * @var bool For save() - save to the database only, without any events.
105     */
106    public const SEND_NONE = true;
107
108    /**
109     * @var bool For save() - do emit the change to RCFeeds (usually public).
110     */
111    public const SEND_FEED = false;
112
113    /** @var array */
114    public $mAttribs = [];
115    public $mExtra = [];
116
117    /**
118     * @var PageReference|null
119     */
120    private $mPage = null;
121
122    /**
123     * @var UserIdentity|null
124     */
125    private $mPerformer = null;
126
127    public $numberofWatchingusers = 0; # Dummy to prevent error message in SpecialRecentChangesLinked
128    public $notificationtimestamp;
129
130    /**
131     * @var string|null The expiry time, if this is a temporary watchlist item.
132     */
133    public $watchlistExpiry;
134
135    /**
136     * @var int Line number of recent change. Default -1.
137     */
138    public $counter = -1;
139
140    /**
141     * @var array List of tags to apply
142     */
143    private $tags = [];
144
145    /**
146     * @var EditResult|null EditResult associated with the edit
147     */
148    private $editResult = null;
149
150    private const CHANGE_TYPES = [
151        'edit' => RC_EDIT,
152        'new' => RC_NEW,
153        'log' => RC_LOG,
154        'external' => RC_EXTERNAL,
155        'categorize' => RC_CATEGORIZE,
156    ];
157
158    # Factory methods
159
160    /**
161     * @param mixed $row
162     * @return RecentChange
163     */
164    public static function newFromRow( $row ) {
165        $rc = new RecentChange;
166        $rc->loadFromRow( $row );
167
168        return $rc;
169    }
170
171    /**
172     * Parsing text to RC_* constants
173     * @since 1.24
174     * @param string|array $type Callers must make sure that the given types are valid RC types.
175     * @return int|array RC_TYPE
176     */
177    public static function parseToRCType( $type ) {
178        if ( is_array( $type ) ) {
179            $retval = [];
180            foreach ( $type as $t ) {
181                $retval[] = self::parseToRCType( $t );
182            }
183
184            return $retval;
185        }
186
187        if ( !array_key_exists( $type, self::CHANGE_TYPES ) ) {
188            throw new InvalidArgumentException( "Unknown type '$type'" );
189        }
190        return self::CHANGE_TYPES[$type];
191    }
192
193    /**
194     * Parsing RC_* constants to human-readable test
195     * @since 1.24
196     * @param int $rcType
197     * @return string
198     */
199    public static function parseFromRCType( $rcType ) {
200        return array_search( $rcType, self::CHANGE_TYPES, true ) ?: "$rcType";
201    }
202
203    /**
204     * Get an array of all change types
205     *
206     * @since 1.26
207     *
208     * @return array
209     */
210    public static function getChangeTypes() {
211        return array_keys( self::CHANGE_TYPES );
212    }
213
214    /**
215     * Obtain the recent change with a given rc_id value
216     *
217     * @param int $rcid The rc_id value to retrieve
218     * @return RecentChange|null
219     */
220    public static function newFromId( $rcid ) {
221        return self::newFromConds( [ 'rc_id' => $rcid ], __METHOD__ );
222    }
223
224    /**
225     * Find the first recent change matching some specific conditions
226     *
227     * @param array $conds Array of conditions
228     * @param mixed $fname Override the method name in profiling/logs
229     * @param int $dbType DB_* constant
230     *
231     * @return RecentChange|null
232     */
233    public static function newFromConds(
234        $conds,
235        $fname = __METHOD__,
236        $dbType = DB_REPLICA
237    ) {
238        $icp = MediaWikiServices::getInstance()->getConnectionProvider();
239
240        $db = ( $dbType === DB_REPLICA ) ? $icp->getReplicaDatabase() : $icp->getPrimaryDatabase();
241
242        $rcQuery = self::getQueryInfo();
243        $row = $db->selectRow(
244            $rcQuery['tables'], $rcQuery['fields'], $conds, $fname, [], $rcQuery['joins']
245        );
246        if ( $row !== false ) {
247            return self::newFromRow( $row );
248        } else {
249            return null;
250        }
251    }
252
253    /**
254     * Return the tables, fields, and join conditions to be selected to create
255     * a new recentchanges object.
256     *
257     * Since 1.34, rc_user and rc_user_text have not been present in the
258     * database, but they continue to be available in query results as
259     * aliases.
260     *
261     * @since 1.31
262     * @return array[] With three keys:
263     *   - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
264     *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
265     *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
266     * @phan-return array{tables:string[],fields:string[],joins:array}
267     */
268    public static function getQueryInfo() {
269        $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'rc_comment' );
270        // Optimizer sometimes refuses to pick up the correct join order (T311360)
271        $commentQuery['joins']['comment_rc_comment'][0] = 'STRAIGHT_JOIN';
272        return [
273            'tables' => [
274                'recentchanges',
275                'recentchanges_actor' => 'actor'
276            ] + $commentQuery['tables'],
277            'fields' => [
278                'rc_id',
279                'rc_timestamp',
280                'rc_namespace',
281                'rc_title',
282                'rc_minor',
283                'rc_bot',
284                'rc_new',
285                'rc_cur_id',
286                'rc_this_oldid',
287                'rc_last_oldid',
288                'rc_type',
289                'rc_source',
290                'rc_patrolled',
291                'rc_ip',
292                'rc_old_len',
293                'rc_new_len',
294                'rc_deleted',
295                'rc_logid',
296                'rc_log_type',
297                'rc_log_action',
298                'rc_params',
299                'rc_actor',
300                'rc_user' => 'recentchanges_actor.actor_user',
301                'rc_user_text' => 'recentchanges_actor.actor_name',
302            ] + $commentQuery['fields'],
303            'joins' => [
304                'recentchanges_actor' => [ 'STRAIGHT_JOIN', 'actor_id=rc_actor' ]
305            ] + $commentQuery['joins'],
306        ];
307    }
308
309    public function __construct() {
310        $this->deprecatePublicPropertyFallback(
311            'mTitle',
312            '1.37',
313            function () {
314                return Title::castFromPageReference( $this->mPage );
315            },
316            function ( ?Title $title ) {
317                $this->mPage = $title;
318            }
319        );
320    }
321
322    # Accessors
323
324    /**
325     * @param array $attribs
326     */
327    public function setAttribs( $attribs ) {
328        $this->mAttribs = $attribs;
329    }
330
331    /**
332     * @param array $extra
333     */
334    public function setExtra( $extra ) {
335        $this->mExtra = $extra;
336    }
337
338    /**
339     * @deprecated since 1.37, use getPage() instead.
340     * @return Title
341     */
342    public function getTitle() {
343        $this->mPage = Title::castFromPageReference( $this->getPage() );
344        return $this->mPage ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
345    }
346
347    /**
348     * @since 1.37
349     * @return ?PageReference
350     */
351    public function getPage(): ?PageReference {
352        if ( !$this->mPage ) {
353            // NOTE: As per the 1.36 release, we always provide rc_title,
354            //       even in cases where it doesn't really make sense.
355            //       In the future, rc_title may be nullable, or we may use
356            //       empty strings in entries that do not refer to a page.
357            if ( ( $this->mAttribs['rc_title'] ?? '' ) === '' ) {
358                return null;
359            }
360
361            // XXX: We could use rc_cur_id to create a PageIdentityValue,
362            //      at least if it's not a special page.
363            //      However, newForCategorization() puts the ID of the categorized page into
364            //      rc_cur_id, but the title of the category page into rc_title.
365            $this->mPage = new PageReferenceValue(
366                (int)$this->mAttribs['rc_namespace'],
367                $this->mAttribs['rc_title'],
368                PageReference::LOCAL
369            );
370        }
371
372        return $this->mPage;
373    }
374
375    /**
376     * Get the UserIdentity of the client that performed this change.
377     *
378     * @since 1.36
379     *
380     * @return UserIdentity
381     */
382    public function getPerformerIdentity(): UserIdentity {
383        if ( !$this->mPerformer ) {
384            $this->mPerformer = $this->getUserIdentityFromAnyId(
385                $this->mAttribs['rc_user'] ?? null,
386                $this->mAttribs['rc_user_text'] ?? null,
387                $this->mAttribs['rc_actor'] ?? null
388            );
389        }
390
391        return $this->mPerformer;
392    }
393
394    /**
395     * Writes the data in this object to the database
396     *
397     * For compatibility reasons, the SEND_ constants internally reference a value
398     * that may seem negated from their purpose (none=true, feed=false). This is
399     * because the parameter used to be called "$noudp", defaulting to false.
400     *
401     * @param bool $send self::SEND_FEED or self::SEND_NONE
402     */
403    public function save( $send = self::SEND_FEED ) {
404        $services = MediaWikiServices::getInstance();
405        $mainConfig = $services->getMainConfig();
406        $putIPinRC = $mainConfig->get( MainConfigNames::PutIPinRC );
407        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
408        if ( !is_array( $this->mExtra ) ) {
409            $this->mExtra = [];
410        }
411
412        if ( !$putIPinRC ) {
413            $this->mAttribs['rc_ip'] = '';
414        }
415
416        # Strict mode fixups (not-NULL fields)
417        foreach ( [ 'minor', 'bot', 'new', 'patrolled', 'deleted' ] as $field ) {
418            $this->mAttribs["rc_$field"] = (int)$this->mAttribs["rc_$field"];
419        }
420        # ...more fixups (NULL fields)
421        foreach ( [ 'old_len', 'new_len' ] as $field ) {
422            $this->mAttribs["rc_$field"] = isset( $this->mAttribs["rc_$field"] )
423                ? (int)$this->mAttribs["rc_$field"]
424                : null;
425        }
426
427        $row = $this->mAttribs;
428
429        # Trim spaces on user supplied text
430        $row['rc_comment'] = trim( $row['rc_comment'] ?? '' );
431
432        # Fixup database timestamps
433        $row['rc_timestamp'] = $dbw->timestamp( $row['rc_timestamp'] );
434
435        # # If we are using foreign keys, an entry of 0 for the page_id will fail, so use NULL
436        if ( $row['rc_cur_id'] == 0 ) {
437            unset( $row['rc_cur_id'] );
438        }
439
440        # Convert mAttribs['rc_comment'] for CommentStore
441        $comment = $row['rc_comment'];
442        unset( $row['rc_comment'], $row['rc_comment_text'], $row['rc_comment_data'] );
443        $row += $services->getCommentStore()->insert( $dbw, 'rc_comment', $comment );
444
445        # Normalize UserIdentity to actor ID
446        $user = $this->getPerformerIdentity();
447        $row['rc_actor'] = $services->getActorStore()->acquireActorId( $user, $dbw );
448        unset( $row['rc_user'], $row['rc_user_text'] );
449
450        # Don't reuse an existing rc_id for the new row, if one happens to be
451        # set for some reason.
452        unset( $row['rc_id'] );
453
454        # Insert new row
455        $dbw->newInsertQueryBuilder()
456            ->insertInto( 'recentchanges' )
457            ->row( $row )
458            ->caller( __METHOD__ )->execute();
459
460        # Set the ID
461        $this->mAttribs['rc_id'] = $dbw->insertId();
462
463        # Notify extensions
464        $hookRunner = new HookRunner( $services->getHookContainer() );
465        $hookRunner->onRecentChange_save( $this );
466
467        // Apply revert tags (if needed)
468        if ( $this->editResult !== null && count( $this->editResult->getRevertTags() ) ) {
469            ChangeTags::addTags(
470                $this->editResult->getRevertTags(),
471                $this->mAttribs['rc_id'],
472                $this->mAttribs['rc_this_oldid'],
473                $this->mAttribs['rc_logid'],
474                FormatJson::encode( $this->editResult ),
475                $this
476            );
477        }
478
479        if ( count( $this->tags ) ) {
480            // $this->tags may contain revert tags we already applied above, they will
481            // just be ignored.
482            ChangeTags::addTags(
483                $this->tags,
484                $this->mAttribs['rc_id'],
485                $this->mAttribs['rc_this_oldid'],
486                $this->mAttribs['rc_logid'],
487                null,
488                $this
489            );
490        }
491
492        if ( $send === self::SEND_FEED ) {
493            // Emit the change to external applications via RCFeeds.
494            $this->notifyRCFeeds();
495        }
496
497        # E-mail notifications
498        if ( self::isEnotifEnabled( $mainConfig ) ) {
499            $userFactory = $services->getUserFactory();
500            $editor = $userFactory->newFromUserIdentity( $this->getPerformerIdentity() );
501            $page = $this->getPage();
502            $title = Title::castFromPageReference( $page );
503
504            // Never send an RC notification email about categorization changes
505            if (
506                $title &&
507                $hookRunner->onAbortEmailNotification( $editor, $title, $this ) &&
508                $this->mAttribs['rc_type'] != RC_CATEGORIZE
509            ) {
510                // @FIXME: This would be better as an extension hook
511                // Send emails or email jobs once this row is safely committed
512                $dbw->onTransactionCommitOrIdle(
513                    function () use ( $editor, $title ) {
514                        $enotif = new EmailNotification();
515                        $enotif->notifyOnPageChange(
516                            $editor,
517                            $title,
518                            $this->mAttribs['rc_timestamp'],
519                            $this->mAttribs['rc_comment'],
520                            $this->mAttribs['rc_minor'],
521                            $this->mAttribs['rc_last_oldid'],
522                            $this->mExtra['pageStatus']
523                        );
524                    },
525                    __METHOD__
526                );
527            }
528        }
529
530        $jobs = [];
531        // Flush old entries from the `recentchanges` table
532        if ( mt_rand( 0, 9 ) == 0 ) {
533            $jobs[] = RecentChangesUpdateJob::newPurgeJob();
534        }
535        // Update the cached list of active users
536        if ( $this->mAttribs['rc_user'] > 0 ) {
537            $jobs[] = RecentChangesUpdateJob::newCacheUpdateJob();
538        }
539        $services->getJobQueueGroup()->lazyPush( $jobs );
540    }
541
542    /**
543     * Notify all the feeds about the change.
544     * @param array|null $feeds Optional feeds to send to, defaults to $wgRCFeeds
545     */
546    public function notifyRCFeeds( array $feeds = null ) {
547        $feeds ??=
548            MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCFeeds );
549
550        $performer = $this->getPerformerIdentity();
551
552        foreach ( $feeds as $params ) {
553            $params += [
554                'omit_bots' => false,
555                'omit_anon' => false,
556                'omit_user' => false,
557                'omit_minor' => false,
558                'omit_patrolled' => false,
559            ];
560
561            if (
562                ( $params['omit_bots'] && $this->mAttribs['rc_bot'] ) ||
563                ( $params['omit_anon'] && !$performer->isRegistered() ) ||
564                ( $params['omit_user'] && $performer->isRegistered() ) ||
565                ( $params['omit_minor'] && $this->mAttribs['rc_minor'] ) ||
566                ( $params['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) ||
567                $this->mAttribs['rc_type'] == RC_EXTERNAL
568            ) {
569                continue;
570            }
571
572            $actionComment = $this->mExtra['actionCommentIRC'] ?? null;
573
574            $feed = RCFeed::factory( $params );
575            $feed->notify( $this, $actionComment );
576        }
577    }
578
579    /**
580     * Mark this RecentChange as patrolled
581     *
582     * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and
583     * 'markedaspatrollederror-noautopatrol' as errors
584     * @param Authority $performer User performing the action
585     * @param bool|null $auto Unused. Passing true logs a warning.
586     * @param string|string[]|null $tags Change tags to add to the patrol log entry
587     *   ($user should be able to add the specified tags before this is called)
588     * @return array[] Array of permissions errors, see PermissionManager::getPermissionErrors()
589     */
590    public function doMarkPatrolled( Authority $performer, $auto = null, $tags = null ) {
591        if ( $auto ) {
592            wfWarn( __METHOD__ . ' with $auto = true' );
593            return [];
594        }
595        $services = MediaWikiServices::getInstance();
596        $mainConfig = $services->getMainConfig();
597        $useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
598        $useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
599        $useFilePatrol = $mainConfig->get( MainConfigNames::UseFilePatrol );
600        // Fix up $tags so that the MarkPatrolled hook below always gets an array
601        if ( $tags === null ) {
602            $tags = [];
603        } elseif ( is_string( $tags ) ) {
604            $tags = [ $tags ];
605        }
606
607        $status = PermissionStatus::newEmpty();
608        // If recentchanges patrol is disabled, only new pages or new file versions
609        // can be patrolled, provided the appropriate config variable is set
610        if ( !$useRCPatrol && ( !$useNPPatrol || $this->getAttribute( 'rc_type' ) != RC_NEW ) &&
611            ( !$useFilePatrol || !( $this->getAttribute( 'rc_type' ) == RC_LOG &&
612            $this->getAttribute( 'rc_log_type' ) == 'upload' ) ) ) {
613            $status->fatal( 'rcpatroldisabled' );
614        }
615        $performer->authorizeWrite( 'patrol', $this->getTitle(), $status );
616        $user = $services->getUserFactory()->newFromAuthority( $performer );
617        $hookRunner = new HookRunner( $services->getHookContainer() );
618        if ( !$hookRunner->onMarkPatrolled(
619            $this->getAttribute( 'rc_id' ), $user, false, false, $tags )
620        ) {
621            $status->fatal( 'hookaborted' );
622        }
623        // Users without the 'autopatrol' right can't patrol their own revisions
624        if ( $performer->getUser()->getName() === $this->getAttribute( 'rc_user_text' ) &&
625            !$performer->isAllowed( 'autopatrol' )
626        ) {
627            $status->fatal( 'markedaspatrollederror-noautopatrol' );
628        }
629        if ( !$status->isGood() ) {
630            return $status->toLegacyErrorArray();
631        }
632        // If the change was patrolled already, do nothing
633        if ( $this->getAttribute( 'rc_patrolled' ) ) {
634            return [];
635        }
636        // Attempt to set the 'patrolled' flag in RC database
637        $affectedRowCount = $this->reallyMarkPatrolled();
638
639        if ( $affectedRowCount === 0 ) {
640            // Query succeeded but no rows change, e.g. another request
641            // patrolled the same change just before us.
642            // Avoid duplicate log entry (T196182).
643            return [];
644        }
645
646        // Log this patrol event
647        PatrolLog::record( $this, false, $performer->getUser(), $tags );
648
649        $hookRunner->onMarkPatrolledComplete(
650            $this->getAttribute( 'rc_id' ), $user, false, false );
651
652        return [];
653    }
654
655    /**
656     * Mark this RecentChange patrolled, without error checking
657     *
658     * @return int Number of database rows changed, usually 1, but 0 if
659     * another request already patrolled it in the mean time.
660     */
661    public function reallyMarkPatrolled() {
662        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
663        $dbw->newUpdateQueryBuilder()
664            ->update( 'recentchanges' )
665            ->set( [ 'rc_patrolled' => self::PRC_PATROLLED ] )
666            ->where( [
667                'rc_id' => $this->getAttribute( 'rc_id' ),
668                'rc_patrolled' => self::PRC_UNPATROLLED,
669            ] )
670            ->caller( __METHOD__ )->execute();
671        $affectedRowCount = $dbw->affectedRows();
672        // The change was patrolled already, do nothing
673        if ( $affectedRowCount === 0 ) {
674            return 0;
675        }
676        // Invalidate the page cache after the page has been patrolled
677        // to make sure that the Patrol link isn't visible any longer!
678        $this->getTitle()->invalidateCache();
679
680        // Enqueue a reverted tag update (in case the edit was a revert)
681        $revisionId = $this->getAttribute( 'rc_this_oldid' );
682        if ( $revisionId ) {
683            $revertedTagUpdateManager =
684                MediaWikiServices::getInstance()->getRevertedTagUpdateManager();
685            $revertedTagUpdateManager->approveRevertedTagForRevision( $revisionId );
686        }
687
688        return $affectedRowCount;
689    }
690
691    /**
692     * Makes an entry in the database corresponding to an edit
693     *
694     * @since 1.36 Added $editResult parameter
695     *
696     * @param string $timestamp
697     * @param PageIdentity $page
698     * @param bool $minor
699     * @param UserIdentity $user
700     * @param string $comment
701     * @param int $oldId
702     * @param string $lastTimestamp
703     * @param bool $bot
704     * @param string $ip
705     * @param int $oldSize
706     * @param int $newSize
707     * @param int $newId
708     * @param int $patrol
709     * @param string[] $tags
710     * @param EditResult|null $editResult EditResult associated with this edit. Can be safely
711     *  skipped if the edit is not a revert. Used only for marking revert tags.
712     *
713     * @return RecentChange
714     */
715    public static function notifyEdit(
716        $timestamp, $page, $minor, $user, $comment, $oldId, $lastTimestamp,
717        $bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0,
718        $tags = [], EditResult $editResult = null
719    ) {
720        Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );
721
722        $rc = new RecentChange;
723        $rc->mPage = $page;
724        $rc->mPerformer = $user;
725        $rc->mAttribs = [
726            'rc_timestamp' => $timestamp,
727            'rc_namespace' => $page->getNamespace(),
728            'rc_title' => $page->getDBkey(),
729            'rc_type' => RC_EDIT,
730            'rc_source' => self::SRC_EDIT,
731            'rc_minor' => $minor ? 1 : 0,
732            'rc_cur_id' => $page->getId(),
733            'rc_user' => $user->getId(),
734            'rc_user_text' => $user->getName(),
735            'rc_comment' => &$comment,
736            'rc_comment_text' => &$comment,
737            'rc_comment_data' => null,
738            'rc_this_oldid' => (int)$newId,
739            'rc_last_oldid' => $oldId,
740            'rc_bot' => $bot ? 1 : 0,
741            'rc_ip' => self::checkIPAddress( $ip ),
742            'rc_patrolled' => intval( $patrol ),
743            'rc_new' => 0, # obsolete
744            'rc_old_len' => $oldSize,
745            'rc_new_len' => $newSize,
746            'rc_deleted' => 0,
747            'rc_logid' => 0,
748            'rc_log_type' => null,
749            'rc_log_action' => '',
750            'rc_params' => ''
751        ];
752
753        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
754        $formatter = MediaWikiServices::getInstance()->getTitleFormatter();
755
756        $rc->mExtra = [
757            'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ),
758            'lastTimestamp' => $lastTimestamp,
759            'oldSize' => $oldSize,
760            'newSize' => $newSize,
761            'pageStatus' => 'changed'
762        ];
763
764        DeferredUpdates::addCallableUpdate(
765            static function () use ( $rc, $tags, $editResult ) {
766                $rc->addTags( $tags );
767                $rc->setEditResult( $editResult );
768                $rc->save();
769            },
770            DeferredUpdates::POSTSEND,
771            MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase()
772        );
773
774        return $rc;
775    }
776
777    /**
778     * Makes an entry in the database corresponding to page creation
779     * @note $page must reflect the state of the database after the page creation. In particular,
780     *       $page->getId() must return the newly assigned page ID.
781     *
782     * @param string $timestamp
783     * @param PageIdentity $page
784     * @param bool $minor
785     * @param UserIdentity $user
786     * @param string $comment
787     * @param bool $bot
788     * @param string $ip
789     * @param int $size
790     * @param int $newId
791     * @param int $patrol
792     * @param string[] $tags
793     *
794     * @return RecentChange
795     */
796    public static function notifyNew(
797        $timestamp,
798        $page, $minor, $user, $comment, $bot,
799        $ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = []
800    ) {
801        Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );
802
803        $rc = new RecentChange;
804        $rc->mPage = $page;
805        $rc->mPerformer = $user;
806        $rc->mAttribs = [
807            'rc_timestamp' => $timestamp,
808            'rc_namespace' => $page->getNamespace(),
809            'rc_title' => $page->getDBkey(),
810            'rc_type' => RC_NEW,
811            'rc_source' => self::SRC_NEW,
812            'rc_minor' => $minor ? 1 : 0,
813            'rc_cur_id' => $page->getId(),
814            'rc_user' => $user->getId(),
815            'rc_user_text' => $user->getName(),
816            'rc_comment' => &$comment,
817            'rc_comment_text' => &$comment,
818            'rc_comment_data' => null,
819            'rc_this_oldid' => (int)$newId,
820            'rc_last_oldid' => 0,
821            'rc_bot' => $bot ? 1 : 0,
822            'rc_ip' => self::checkIPAddress( $ip ),
823            'rc_patrolled' => intval( $patrol ),
824            'rc_new' => 1, # obsolete
825            'rc_old_len' => 0,
826            'rc_new_len' => $size,
827            'rc_deleted' => 0,
828            'rc_logid' => 0,
829            'rc_log_type' => null,
830            'rc_log_action' => '',
831            'rc_params' => ''
832        ];
833
834        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
835        $formatter = MediaWikiServices::getInstance()->getTitleFormatter();
836
837        $rc->mExtra = [
838            'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ),
839            'lastTimestamp' => 0,
840            'oldSize' => 0,
841            'newSize' => $size,
842            'pageStatus' => 'created'
843        ];
844
845        DeferredUpdates::addCallableUpdate(
846            static function () use ( $rc, $tags ) {
847                $rc->addTags( $tags );
848                $rc->save();
849            },
850            DeferredUpdates::POSTSEND,
851            MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase()
852        );
853
854        return $rc;
855    }
856
857    /**
858     * @param string $timestamp
859     * @param PageReference $logPage
860     * @param UserIdentity $user
861     * @param string $actionComment
862     * @param string $ip
863     * @param string $type
864     * @param string $action
865     * @param PageReference $target
866     * @param string $logComment
867     * @param string $params
868     * @param int $newId
869     * @param string $actionCommentIRC
870     *
871     * @return bool
872     */
873    public static function notifyLog( $timestamp,
874        $logPage, $user, $actionComment, $ip, $type,
875        $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = ''
876    ) {
877        $logRestrictions = MediaWikiServices::getInstance()->getMainConfig()
878            ->get( MainConfigNames::LogRestrictions );
879
880        # Don't add private logs to RC!
881        if ( isset( $logRestrictions[$type] ) && $logRestrictions[$type] != '*' ) {
882            return false;
883        }
884        $rc = self::newLogEntry( $timestamp,
885            $logPage, $user, $actionComment, $ip, $type, $action,
886            $target, $logComment, $params, $newId, $actionCommentIRC );
887        $rc->save();
888
889        return true;
890    }
891
892    /**
893     * @param string $timestamp
894     * @param PageReference $logPage
895     * @param UserIdentity $user
896     * @param string $actionComment
897     * @param string $ip
898     * @param string $type
899     * @param string $action
900     * @param PageReference $target
901     * @param string $logComment
902     * @param string $params
903     * @param int $newId
904     * @param string $actionCommentIRC
905     * @param int $revId Id of associated revision, if any
906     * @param bool $isPatrollable Whether this log entry is patrollable
907     * @param bool|null $forceBotFlag Override the default behavior and set bot flag to
908     *     the value of the argument. When omitted or null, it falls back to the global state.
909     *
910     * @return RecentChange
911     */
912    public static function newLogEntry( $timestamp,
913        $logPage, $user, $actionComment, $ip,
914        $type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '',
915        $revId = 0, $isPatrollable = false, $forceBotFlag = null
916    ) {
917        global $wgRequest;
918
919        $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
920
921        # # Get pageStatus for email notification
922        switch ( $type . '-' . $action ) {
923            case 'delete-delete':
924            case 'delete-delete_redir':
925            case 'delete-delete_redir2':
926                $pageStatus = 'deleted';
927                break;
928            case 'move-move':
929            case 'move-move_redir':
930                $pageStatus = 'moved';
931                break;
932            case 'delete-restore':
933                $pageStatus = 'restored';
934                break;
935            case 'upload-upload':
936                $pageStatus = 'created';
937                break;
938            case 'upload-overwrite':
939            default:
940                $pageStatus = 'changed';
941                break;
942        }
943
944        // Allow unpatrolled status for patrollable log entries
945        $canAutopatrol = $permissionManager->userHasRight( $user, 'autopatrol' );
946        $markPatrolled = $isPatrollable ? $canAutopatrol : true;
947
948        if ( $target instanceof PageIdentity && $target->canExist() ) {
949            $pageId = $target->getId();
950        } else {
951            $pageId = 0;
952        }
953
954        if ( $forceBotFlag !== null ) {
955            $bot = (int)$forceBotFlag;
956        } else {
957            $bot = $permissionManager->userHasRight( $user, 'bot' ) ?
958                (int)$wgRequest->getBool( 'bot', true ) : 0;
959        }
960
961        $rc = new RecentChange;
962        $rc->mPage = $target;
963        $rc->mPerformer = $user;
964        $rc->mAttribs = [
965            'rc_timestamp' => $timestamp,
966            'rc_namespace' => $target->getNamespace(),
967            'rc_title' => $target->getDBkey(),
968            'rc_type' => RC_LOG,
969            'rc_source' => self::SRC_LOG,
970            'rc_minor' => 0,
971            'rc_cur_id' => $pageId,
972            'rc_user' => $user->getId(),
973            'rc_user_text' => $user->getName(),
974            'rc_comment' => &$logComment,
975            'rc_comment_text' => &$logComment,
976            'rc_comment_data' => null,
977            'rc_this_oldid' => (int)$revId,
978            'rc_last_oldid' => 0,
979            'rc_bot' => $bot,
980            'rc_ip' => self::checkIPAddress( $ip ),
981            'rc_patrolled' => $markPatrolled ? self::PRC_AUTOPATROLLED : self::PRC_UNPATROLLED,
982            'rc_new' => 0, # obsolete
983            'rc_old_len' => null,
984            'rc_new_len' => null,
985            'rc_deleted' => 0,
986            'rc_logid' => $newId,
987            'rc_log_type' => $type,
988            'rc_log_action' => $action,
989            'rc_params' => $params
990        ];
991
992        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
993        $formatter = MediaWikiServices::getInstance()->getTitleFormatter();
994
995        $rc->mExtra = [
996            // XXX: This does not correspond to rc_namespace/rc_title/rc_cur_id.
997            //      Is that intentional? For all other kinds of RC entries, prefixedDBkey
998            //      matches rc_namespace/rc_title. Do we even need $logPage?
999            'prefixedDBkey' => $formatter->getPrefixedDBkey( $logPage ),
1000            'lastTimestamp' => 0,
1001            'actionComment' => $actionComment, // the comment appended to the action, passed from LogPage
1002            'pageStatus' => $pageStatus,
1003            'actionCommentIRC' => $actionCommentIRC
1004        ];
1005
1006        return $rc;
1007    }
1008
1009    /**
1010     * Constructs a RecentChange object for the given categorization
1011     * This does not call save() on the object and thus does not write to the db
1012     *
1013     * @since 1.27
1014     *
1015     * @param string $timestamp Timestamp of the recent change to occur
1016     * @param PageIdentity $categoryTitle the category a page is being added to or removed from
1017     * @param UserIdentity|null $user User object of the user that made the change
1018     * @param string $comment Change summary
1019     * @param PageIdentity $pageTitle the page that is being added or removed
1020     * @param int $oldRevId Parent revision ID of this change
1021     * @param int $newRevId Revision ID of this change
1022     * @param string $lastTimestamp Parent revision timestamp of this change
1023     * @param bool $bot true, if the change was made by a bot
1024     * @param string $ip IP address of the user, if the change was made anonymously
1025     * @param int $deleted Indicates whether the change has been deleted
1026     * @param bool|null $added true, if the category was added, false for removed
1027     *
1028     * @return RecentChange
1029     */
1030    public static function newForCategorization(
1031        $timestamp,
1032        PageIdentity $categoryTitle,
1033        ?UserIdentity $user,
1034        $comment,
1035        PageIdentity $pageTitle,
1036        $oldRevId,
1037        $newRevId,
1038        $lastTimestamp,
1039        $bot,
1040        $ip = '',
1041        $deleted = 0,
1042        $added = null
1043    ) {
1044        // Done in a backwards compatible way.
1045        $categoryWikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()
1046            ->newFromTitle( $categoryTitle );
1047
1048        '@phan-var WikiCategoryPage $categoryWikiPage';
1049        $params = [
1050            'hidden-cat' => $categoryWikiPage->isHidden()
1051        ];
1052        if ( $added !== null ) {
1053            $params['added'] = $added;
1054        }
1055
1056        if ( !$user ) {
1057            // XXX: when and why do we need this?
1058            $user = MediaWikiServices::getInstance()->getActorStore()->getUnknownActor();
1059        }
1060
1061        $rc = new RecentChange;
1062        $rc->mPage = $categoryTitle;
1063        $rc->mPerformer = $user;
1064        $rc->mAttribs = [
1065            'rc_timestamp' => MWTimestamp::convert( TS_MW, $timestamp ),
1066            'rc_namespace' => $categoryTitle->getNamespace(),
1067            'rc_title' => $categoryTitle->getDBkey(),
1068            'rc_type' => RC_CATEGORIZE,
1069            'rc_source' => self::SRC_CATEGORIZE,
1070            'rc_minor' => 0,
1071            // XXX: rc_cur_id does not correspond to rc_namespace/rc_title.
1072            // It's because when the page (rc_cur_id) is deleted, we want
1073            // to delete the categorization entries, too (see LinksDeletionUpdate).
1074            'rc_cur_id' => $pageTitle->getId(),
1075            'rc_user' => $user->getId(),
1076            'rc_user_text' => $user->getName(),
1077            'rc_comment' => &$comment,
1078            'rc_comment_text' => &$comment,
1079            'rc_comment_data' => null,
1080            'rc_this_oldid' => (int)$newRevId,
1081            'rc_last_oldid' => $oldRevId,
1082            'rc_bot' => $bot ? 1 : 0,
1083            'rc_ip' => self::checkIPAddress( $ip ),
1084            'rc_patrolled' => self::PRC_AUTOPATROLLED, // Always patrolled, just like log entries
1085            'rc_new' => 0, # obsolete
1086            'rc_old_len' => null,
1087            'rc_new_len' => null,
1088            'rc_deleted' => $deleted,
1089            'rc_logid' => 0,
1090            'rc_log_type' => null,
1091            'rc_log_action' => '',
1092            'rc_params' => serialize( $params )
1093        ];
1094
1095        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
1096        $formatter = MediaWikiServices::getInstance()->getTitleFormatter();
1097
1098        $rc->mExtra = [
1099            'prefixedDBkey' => $formatter->getPrefixedDBkey( $categoryTitle ),
1100            'lastTimestamp' => $lastTimestamp,
1101            'oldSize' => 0,
1102            'newSize' => 0,
1103            'pageStatus' => 'changed'
1104        ];
1105
1106        return $rc;
1107    }
1108
1109    /**
1110     * Get a parameter value
1111     *
1112     * @since 1.27
1113     *
1114     * @param string $name parameter name
1115     * @return mixed
1116     */
1117    public function getParam( $name ) {
1118        $params = $this->parseParams();
1119        return $params[$name] ?? null;
1120    }
1121
1122    /**
1123     * Initialises the members of this object from a mysql row object
1124     *
1125     * @param mixed $row
1126     */
1127    public function loadFromRow( $row ) {
1128        $this->mAttribs = get_object_vars( $row );
1129        $this->mAttribs['rc_timestamp'] = wfTimestamp( TS_MW, $this->mAttribs['rc_timestamp'] );
1130        // rc_deleted MUST be set
1131        $this->mAttribs['rc_deleted'] = $row->rc_deleted;
1132
1133        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1134        $comment = MediaWikiServices::getInstance()->getCommentStore()
1135            // Legacy because $row may have come from self::selectFields()
1136            ->getCommentLegacy( $dbr, 'rc_comment', $row, true )
1137            ->text;
1138        $this->mAttribs['rc_comment'] = &$comment;
1139        $this->mAttribs['rc_comment_text'] = &$comment;
1140        $this->mAttribs['rc_comment_data'] = null;
1141
1142        $this->mPerformer = $this->getUserIdentityFromAnyId(
1143            $row->rc_user ?? null,
1144            $row->rc_user_text ?? null,
1145            $row->rc_actor ?? null
1146        );
1147        $this->mAttribs['rc_user'] = $this->mPerformer->getId();
1148        $this->mAttribs['rc_user_text'] = $this->mPerformer->getName();
1149
1150        // Watchlist expiry.
1151        if ( isset( $row->we_expiry ) && $row->we_expiry ) {
1152            $this->watchlistExpiry = wfTimestamp( TS_MW, $row->we_expiry );
1153        }
1154    }
1155
1156    /**
1157     * Get an attribute value
1158     *
1159     * @param string $name Attribute name
1160     * @return mixed
1161     */
1162    public function getAttribute( $name ) {
1163        if ( $name === 'rc_comment' ) {
1164            return MediaWikiServices::getInstance()->getCommentStore()
1165                ->getComment( 'rc_comment', $this->mAttribs, true )->text;
1166        }
1167
1168        if ( $name === 'rc_user' || $name === 'rc_user_text' || $name === 'rc_actor' ) {
1169            $user = $this->getPerformerIdentity();
1170
1171            if ( $name === 'rc_user' ) {
1172                return $user->getId();
1173            }
1174            if ( $name === 'rc_user_text' ) {
1175                return $user->getName();
1176            }
1177            if ( $name === 'rc_actor' ) {
1178                // NOTE: rc_actor exists in the database, but application logic should not use it.
1179                wfDeprecatedMsg( 'Accessing deprecated field rc_actor', '1.36' );
1180                $actorStore = MediaWikiServices::getInstance()->getActorStore();
1181                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1182                return $actorStore->findActorId( $user, $db );
1183            }
1184        }
1185
1186        return $this->mAttribs[$name] ?? null;
1187    }
1188
1189    /**
1190     * @return array
1191     */
1192    public function getAttributes() {
1193        return $this->mAttribs;
1194    }
1195
1196    /**
1197     * Gets the end part of the diff URL associated with this object
1198     * Blank if no diff link should be displayed
1199     * @param bool $forceCur
1200     * @return string
1201     */
1202    public function diffLinkTrail( $forceCur ) {
1203        if ( $this->mAttribs['rc_type'] == RC_EDIT ) {
1204            $trail = "curid=" . (int)( $this->mAttribs['rc_cur_id'] ) .
1205                "&oldid=" . (int)( $this->mAttribs['rc_last_oldid'] );
1206            if ( $forceCur ) {
1207                $trail .= '&diff=0';
1208            } else {
1209                $trail .= '&diff=' . (int)( $this->mAttribs['rc_this_oldid'] );
1210            }
1211        } else {
1212            $trail = '';
1213        }
1214
1215        return $trail;
1216    }
1217
1218    /**
1219     * Returns the change size (HTML).
1220     * The lengths can be given optionally.
1221     * @param int $old
1222     * @param int $new
1223     * @return string
1224     */
1225    public function getCharacterDifference( $old = 0, $new = 0 ) {
1226        if ( $old === 0 ) {
1227            $old = $this->mAttribs['rc_old_len'];
1228        }
1229        if ( $new === 0 ) {
1230            $new = $this->mAttribs['rc_new_len'];
1231        }
1232        if ( $old === null || $new === null ) {
1233            return '';
1234        }
1235
1236        return ChangesList::showCharacterDifference( $old, $new );
1237    }
1238
1239    private static function checkIPAddress( $ip ) {
1240        global $wgRequest;
1241
1242        if ( $ip ) {
1243            if ( !IPUtils::isIPAddress( $ip ) ) {
1244                throw new RuntimeException( "Attempt to write \"" . $ip .
1245                    "\" as an IP address into recent changes" );
1246            }
1247        } else {
1248            $ip = $wgRequest->getIP();
1249            if ( !$ip ) {
1250                $ip = '';
1251            }
1252        }
1253
1254        return $ip;
1255    }
1256
1257    /**
1258     * Check whether the given timestamp is new enough to have a RC row with a given tolerance
1259     * as the recentchanges table might not be cleared out regularly (so older entries might exist)
1260     * or rows which will be deleted soon shouldn't be included.
1261     *
1262     * @param mixed $timestamp MWTimestamp compatible timestamp
1263     * @param int $tolerance Tolerance in seconds
1264     * @return bool
1265     */
1266    public static function isInRCLifespan( $timestamp, $tolerance = 0 ) {
1267        $rcMaxAge =
1268            MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCMaxAge );
1269
1270        return (int)wfTimestamp( TS_UNIX, $timestamp ) > time() - $tolerance - $rcMaxAge;
1271    }
1272
1273    /**
1274     * Whether e-mail notifications are generally enabled on this wiki.
1275     *
1276     * This is used for:
1277     *
1278     * - performance optimization in RecentChange::save().
1279     *   After an edit, whether or not we need to use the EmailNotification
1280     *   service to determine which EnotifNotifyJob to dispatch.
1281     *
1282     * - performance optmization in WatchlistManager.
1283     *   After using reset ("Mark all pages as seen") on Special:Watchlist,
1284     *   whether to only look for user talk data to reset, or whether to look
1285     *   at all possible pages for timestamps to reset.
1286     *
1287     * TODO: Determine whether these optimizations still make sense.
1288     *
1289     * FIXME: The $wgShowUpdatedMarker variable was added to this condtion
1290     * in 2008 (2cf12c973d, SVN r35001) because at the time the per-user
1291     * "last seen" marker for watchlist and page history, was managed by
1292     * the EmailNotification/UserMailed classes. As of August 2022, this
1293     * appears to no longer be the case.
1294     *
1295     * @since 1.40
1296     * @param Config $conf
1297     * @return bool
1298     */
1299    public static function isEnotifEnabled( Config $conf ): bool {
1300        return $conf->get( MainConfigNames::EnotifUserTalk ) ||
1301            $conf->get( MainConfigNames::EnotifWatchlist ) ||
1302            $conf->get( MainConfigNames::ShowUpdatedMarker );
1303    }
1304
1305    /**
1306     * Get the extra URL that is given as part of the notification to RCFeed consumers.
1307     *
1308     * This is mainly to facilitate patrolling or other content review.
1309     *
1310     * @since 1.40
1311     * @return string|null URL
1312     */
1313    public function getNotifyUrl() {
1314        $services = MediaWikiServices::getInstance();
1315        $mainConfig = $services->getMainConfig();
1316        $useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
1317        $useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
1318        $localInterwikis = $mainConfig->get( MainConfigNames::LocalInterwikis );
1319        $canonicalServer = $mainConfig->get( MainConfigNames::CanonicalServer );
1320        $script = $mainConfig->get( MainConfigNames::Script );
1321
1322        $type = $this->getAttribute( 'rc_type' );
1323        if ( $type == RC_LOG ) {
1324            $url = null;
1325        } else {
1326            $url = $canonicalServer . $script;
1327            if ( $type == RC_NEW ) {
1328                $query = '?oldid=' . $this->getAttribute( 'rc_this_oldid' );
1329            } else {
1330                $query = '?diff=' . $this->getAttribute( 'rc_this_oldid' )
1331                    . '&oldid=' . $this->getAttribute( 'rc_last_oldid' );
1332            }
1333            if ( $useRCPatrol || ( $this->getAttribute( 'rc_type' ) == RC_NEW && $useNPPatrol ) ) {
1334                $query .= '&rcid=' . $this->getAttribute( 'rc_id' );
1335            }
1336
1337            ( new HookRunner( $services->getHookContainer() ) )->onIRCLineURL( $url, $query, $this );
1338            $url .= $query;
1339        }
1340
1341        return $url;
1342    }
1343
1344    /**
1345     * Parses and returns the rc_params attribute
1346     *
1347     * @since 1.26
1348     * @return mixed|bool false on failed unserialization
1349     */
1350    public function parseParams() {
1351        $rcParams = $this->getAttribute( 'rc_params' );
1352
1353        AtEase::suppressWarnings();
1354        $unserializedParams = unserialize( $rcParams );
1355        AtEase::restoreWarnings();
1356
1357        return $unserializedParams;
1358    }
1359
1360    /**
1361     * Tags to append to the recent change,
1362     * and associated revision/log
1363     *
1364     * @since 1.28
1365     *
1366     * @param string|string[] $tags
1367     */
1368    public function addTags( $tags ) {
1369        if ( is_string( $tags ) ) {
1370            $this->tags[] = $tags;
1371        } else {
1372            $this->tags = array_merge( $tags, $this->tags );
1373        }
1374    }
1375
1376    /**
1377     * Sets the EditResult associated with the edit.
1378     *
1379     * @since 1.36
1380     *
1381     * @param EditResult|null $editResult
1382     */
1383    public function setEditResult( ?EditResult $editResult ) {
1384        $this->editResult = $editResult;
1385    }
1386
1387    /**
1388     * @param string|int|null $userId
1389     * @param string|null $userName
1390     * @param string|int|null $actorId
1391     *
1392     * @return UserIdentity
1393     */
1394    private function getUserIdentityFromAnyId(
1395        $userId,
1396        $userName,
1397        $actorId = null
1398    ): UserIdentity {
1399        // XXX: Is this logic needed elsewhere? Should it be reusable?
1400
1401        $userId = isset( $userId ) ? (int)$userId : null;
1402        $actorId = isset( $actorId ) ? (int)$actorId : 0;
1403
1404        $actorStore = MediaWikiServices::getInstance()->getActorStore();
1405        if ( $userName && $actorId ) {
1406            // Likely the fields are coming from a join on actor table,
1407            // so can definitely build a UserIdentityValue.
1408            return $actorStore->newActorFromRowFields( $userId, $userName, $actorId );
1409        }
1410        if ( $userId !== null ) {
1411            if ( $userName !== null ) {
1412                // NOTE: For IPs and external users, $userId will be 0.
1413                $user = new UserIdentityValue( $userId, $userName );
1414            } else {
1415                $user = $actorStore->getUserIdentityByUserId( $userId );
1416
1417                if ( !$user ) {
1418                    throw new RuntimeException( "User not found by ID: $userId" );
1419                }
1420            }
1421        } elseif ( $actorId > 0 ) {
1422            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1423            $user = $actorStore->getActorById( $actorId, $db );
1424
1425            if ( !$user ) {
1426                throw new RuntimeException( "User not found by actor ID: $actorId" );
1427            }
1428        } elseif ( $userName !== null ) {
1429            $user = $actorStore->getUserIdentityByName( $userName );
1430
1431            if ( !$user ) {
1432                throw new RuntimeException( "User not found by name: $userName" );
1433            }
1434        } else {
1435            throw new RuntimeException( 'At least one of user ID, actor ID or user name must be given' );
1436        }
1437
1438        return $user;
1439    }
1440}