Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.04% covered (danger)
26.04%
175 / 672
10.53% covered (danger)
10.53%
4 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlaggedRevsHooks
26.04% covered (danger)
26.04%
175 / 672
10.53% covered (danger)
10.53%
4 / 38
19979.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 onRevisionUndeleted
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleMergeComplete
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 onPageMoveComplete
48.39% covered (danger)
48.39%
15 / 31
0.00% covered (danger)
0.00%
0 / 1
16.80
 onRevisionDataUpdates
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleRevisionVisibilitySet
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onParserFirstCallInit
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 onParserGetVariableValueSwitch
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onMagicWordwgVariableIDs
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 parserPendingChangeLevel
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 onGetUserPermissionsErrors
37.14% covered (danger)
37.14%
13 / 35
0.00% covered (danger)
0.00%
0 / 1
119.34
 maybeMakeEditReviewed
88.41% covered (warning)
88.41%
61 / 69
0.00% covered (danger)
0.00%
0 / 1
34.70
 editCheckReview
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 isSelfRevertToStable
52.94% covered (warning)
52.94%
18 / 34
0.00% covered (danger)
0.00%
0 / 1
7.61
 maybeNullEditReview
4.76% covered (danger)
4.76%
2 / 42
0.00% covered (danger)
0.00%
0 / 1
266.65
 onRecentChange_save
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
5.05
 maybeIncrementReverts
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 getQueryData
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 editSpacingCheck
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
210
 reviewedEditsCheck
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 wasPreviouslyBlocked
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 recentEditCount
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 recentContentEditCount
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 onUserGetRights
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
4.12
 onRevisionFromEditComplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onPageSaveComplete
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 onAutopromoteCondition
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 1
992
 onUserLoadAfterLoadFromSession
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 onWikiExporter__dumpStableQuery
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 gnsmQueryModifier
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 onEchoGetDefaultNotifiedUsers
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 onUserMergeAccountFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onMergeAccountFromTo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onDeleteAccount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforeRevertedTagUpdate
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
1<?php
2// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
3// phpcs:disable MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic
4
5use MediaWiki\Config\Config;
6use MediaWiki\Deferred\DeferredUpdates;
7use MediaWiki\Extension\GoogleNewsSitemap\Specials\GoogleNewsSitemap;
8use MediaWiki\Extension\Notifications\Model\Event;
9use MediaWiki\Hook\ArticleMergeCompleteHook;
10use MediaWiki\Hook\ArticleRevisionVisibilitySetHook;
11use MediaWiki\Hook\MagicWordwgVariableIDsHook;
12use MediaWiki\Hook\PageMoveCompleteHook;
13use MediaWiki\Hook\ParserFirstCallInitHook;
14use MediaWiki\Hook\ParserGetVariableValueSwitchHook;
15use MediaWiki\Hook\RecentChange_saveHook;
16use MediaWiki\Hook\WikiExporter__dumpStableQueryHook;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
19use MediaWiki\Page\Hook\ArticleUndeleteHook;
20use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
21use MediaWiki\Page\Hook\RevisionUndeletedHook;
22use MediaWiki\Parser\Parser;
23use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
24use MediaWiki\Permissions\Hook\UserGetRightsHook;
25use MediaWiki\Permissions\PermissionManager;
26use MediaWiki\Revision\RevisionLookup;
27use MediaWiki\Revision\RevisionRecord;
28use MediaWiki\Storage\EditResult;
29use MediaWiki\Storage\Hook\BeforeRevertedTagUpdateHook;
30use MediaWiki\Storage\Hook\PageSaveCompleteHook;
31use MediaWiki\Storage\Hook\RevisionDataUpdatesHook;
32use MediaWiki\Title\Title;
33use MediaWiki\User\ActorMigration;
34use MediaWiki\User\Hook\AutopromoteConditionHook;
35use MediaWiki\User\Hook\UserLoadAfterLoadFromSessionHook;
36use MediaWiki\User\User;
37use MediaWiki\User\UserIdentity;
38use MediaWiki\User\UserIdentityUtils;
39use MediaWiki\User\UserNameUtils;
40use Wikimedia\Rdbms\Database;
41use Wikimedia\Rdbms\IDBAccessObject;
42use Wikimedia\Rdbms\IReadableDatabase;
43use Wikimedia\Rdbms\RawSQLValue;
44use Wikimedia\Rdbms\SelectQueryBuilder;
45
46/**
47 * Class containing hooked functions for a FlaggedRevs environment
48 */
49class FlaggedRevsHooks implements
50    ArticleDeleteCompleteHook,
51    ArticleMergeCompleteHook,
52    ArticleRevisionVisibilitySetHook,
53    ArticleUndeleteHook,
54    AutopromoteConditionHook,
55    BeforeRevertedTagUpdateHook,
56    getUserPermissionsErrorsHook,
57    MagicWordwgVariableIDsHook,
58    RevisionFromEditCompleteHook,
59    PageSaveCompleteHook,
60    PageMoveCompleteHook,
61    ParserFirstCallInitHook,
62    ParserGetVariableValueSwitchHook,
63    RecentChange_saveHook,
64    RevisionDataUpdatesHook,
65    RevisionUndeletedHook,
66    UserGetRightsHook,
67    UserLoadAfterLoadFromSessionHook,
68    WikiExporter__dumpStableQueryHook
69{
70
71    private Config $config;
72    private PermissionManager $permissionManager;
73    private RevisionLookup $revisionLookup;
74    private UserNameUtils $userNameUtils;
75    private UserIdentityUtils $userIdentityUtils;
76
77    public function __construct(
78        Config $config,
79        PermissionManager $permissionManager,
80        RevisionLookup $revisionLookup,
81        UserNameUtils $userNameUtils,
82        UserIdentityUtils $userIdentityUtils
83    ) {
84        $this->config = $config;
85        $this->permissionManager = $permissionManager;
86        $this->revisionLookup = $revisionLookup;
87        $this->userNameUtils = $userNameUtils;
88        $this->userIdentityUtils = $userIdentityUtils;
89    }
90
91    /**
92     * @see https://www.mediawiki.org/wiki/Manual:Extension_registration#Customizing_registration
93     */
94    public static function onRegistration() {
95        # Review tier constants...
96        define( 'FR_CHECKED', 0 ); // "basic"/"checked"
97
98        # Inclusion (templates) settings
99        define( 'FR_INCLUDES_CURRENT', 0 );
100        define( 'FR_INCLUDES_STABLE', 2 );
101
102        # Autoreview settings for priviledged users
103        define( 'FR_AUTOREVIEW_NONE', 0 );
104        define( 'FR_AUTOREVIEW_CHANGES', 1 );
105        define( 'FR_AUTOREVIEW_CREATION', 2 );
106        define( 'FR_AUTOREVIEW_CREATION_AND_CHANGES', FR_AUTOREVIEW_CHANGES | FR_AUTOREVIEW_CREATION );
107
108        # User preference for when page views use stable or current page versions
109        define( 'FR_SHOW_STABLE_DEFAULT', 0 ); // page config default; b/c with "false"
110        define( 'FR_SHOW_STABLE_ALWAYS', 1 ); // stable version (current version if none)
111        define( 'FR_SHOW_STABLE_NEVER', 2 ); // current version
112
113        # Autopromote conds (F=70,R=82)
114        # @TODO: move these 6 to core
115        define( 'APCOND_FR_EDITSUMMARYCOUNT', 70821 );
116        define( 'APCOND_FR_NEVERBLOCKED', 70822 );
117        define( 'APCOND_FR_NEVERBOCKED', 70822 ); // b/c
118        define( 'APCOND_FR_UNIQUEPAGECOUNT', 70823 );
119        define( 'APCOND_FR_CONTENTEDITCOUNT', 70824 );
120        define( 'APCOND_FR_USERPAGEBYTES', 70825 );
121        define( 'APCOND_FR_EDITCOUNT', 70826 );
122
123        define( 'APCOND_FR_EDITSPACING', 70827 );
124        define( 'APCOND_FR_CHECKEDEDITCOUNT', 70828 );
125        define( 'APCOND_FR_MAXREVERTEDEDITRATIO', 70829 );
126        define( 'APCOND_FR_NEVERDEMOTED', 70830 );
127    }
128
129    /**
130     * @inheritDoc
131     *
132     * Update flaggedrevs table on revision restore
133     */
134    public function onRevisionUndeleted( $revision, $oldPageID ) {
135        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
136
137        # Some revisions may have had null rev_id values stored when deleted.
138        # This hook is called after insertOn() however, in which case it is set
139        # as a new one.
140        $dbw->newUpdateQueryBuilder()
141            ->update( 'flaggedrevs' )
142            ->set( [ 'fr_page_id' => $revision->getPageId() ] )
143            ->where( [ 'fr_page_id' => $oldPageID, 'fr_rev_id' => $revision->getId() ] )
144            ->caller( __METHOD__ )
145            ->execute();
146    }
147
148    /**
149     * @inheritDoc
150     */
151    public function onArticleMergeComplete( $sourceTitle, $destTitle ) {
152        $oldPageID = $sourceTitle->getArticleID();
153        $newPageID = $destTitle->getArticleID();
154        # Get flagged revisions from old page id that point to destination page
155        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
156
157        $revIDs = $dbw->newSelectQueryBuilder()
158            ->select( 'fr_rev_id' )
159            ->from( 'flaggedrevs' )
160            ->join( 'revision', null, 'fr_rev_id = rev_id' )
161            ->where( [
162                'fr_page_id' => $oldPageID,
163                'rev_page' => $newPageID
164            ] )
165            ->caller( __METHOD__ )
166            ->fetchFieldValues();
167        # Update these rows
168        if ( $revIDs ) {
169            $dbw->newUpdateQueryBuilder()
170                ->update( 'flaggedrevs' )
171                ->set( [ 'fr_page_id' => $newPageID ] )
172                ->where( [ 'fr_page_id' => $oldPageID, 'fr_rev_id' => $revIDs ] )
173                ->caller( __METHOD__ )
174                ->execute();
175        }
176        # Update pages...stable versions possibly lost to another page
177        FlaggedRevs::stableVersionUpdates( $sourceTitle );
178        FlaggedRevs::updateHtmlCaches( $sourceTitle );
179        FlaggedRevs::stableVersionUpdates( $destTitle );
180        FlaggedRevs::updateHtmlCaches( $destTitle );
181    }
182
183    /**
184     * @inheritDoc
185     * (a) Update flaggedrevs page/tracking tables
186     * (b) Autoreview pages moved into reviewable namespaces (bug 19379)
187     */
188    public function onPageMoveComplete(
189        $oLinkTarget,
190        $nLinkTarget,
191        $userIdentity,
192        $pageId,
193        $redirid,
194        $reason,
195        $revision
196    ) {
197        $services = MediaWikiServices::getInstance();
198
199        $ntitle = Title::newFromLinkTarget( $nLinkTarget );
200        $otitle = Title::newFromLinkTarget( $oLinkTarget );
201        if ( FlaggedRevs::inReviewNamespace( $ntitle ) ) {
202            $user = User::newFromIdentity( $userIdentity );
203
204            if ( FlaggedRevs::inReviewNamespace( $otitle ) ) {
205                $fa = FlaggableWikiPage::getTitleInstance( $ntitle );
206                $fa->loadPageData( IDBAccessObject::READ_LATEST );
207                $config = $fa->getStabilitySettings();
208                // Insert a stable log entry if page doesn't have default wiki settings
209                if ( !FRPageConfig::configIsReset( $config ) ) {
210                    FlaggedRevsLog::updateStabilityLogOnMove( $ntitle, $otitle, $reason, $user );
211                }
212            } elseif ( FlaggedRevs::autoReviewNewPages() ) {
213                $fa = FlaggableWikiPage::getTitleInstance( $ntitle );
214                $fa->loadPageData( IDBAccessObject::READ_LATEST );
215                // Re-validate NS/config (new title may not be reviewable)
216                if ( $fa->isReviewable() &&
217                    $services->getPermissionManager()->userCan( 'autoreview', $user, $ntitle )
218                ) {
219                    // Auto-review such edits like new pages...
220                    $revRecord = $services->getRevisionLookup()
221                        ->getRevisionByTitle( $ntitle, 0, IDBAccessObject::READ_LATEST );
222                    if ( $revRecord ) { // sanity
223                        FlaggedRevs::autoReviewEdit(
224                            $fa,
225                            $user,
226                            $revRecord,
227                            null,
228                            true,
229                            true // approve the reverted tag update
230                        );
231                    }
232                }
233            }
234        }
235
236        # Update page and tracking tables and clear cache
237        FlaggedRevs::stableVersionUpdates( $otitle );
238        FlaggedRevs::updateHtmlCaches( $otitle );
239        FlaggedRevs::stableVersionUpdates( $ntitle );
240        FlaggedRevs::updateHtmlCaches( $ntitle );
241    }
242
243    /**
244     * @inheritDoc
245     * (a) Update flaggedrevs page/tracking tables
246     * (b) Pages with stable versions that use this page will be purged
247     * Note: pages with current versions that use this page should already be purged
248     */
249    public function onRevisionDataUpdates(
250        $title, $renderedRevision, &$updates
251    ) {
252        $updates[] = new FRStableVersionUpdate( $title, $renderedRevision );
253        $updates[] = new FRExtraCacheUpdate( $title );
254    }
255
256    /**
257     * @inheritDoc
258     * (a) Update flaggedrevs page/tracking tables
259     * (b) Pages with stable versions that use this page will be purged
260     * Note: pages with current versions that use this page should already be purged
261     */
262    public function onArticleDeleteComplete( $wikiPage, $user, $reason, $id, $content, $logEntry, $count ) {
263        FlaggedRevs::clearTrackingRows( $id );
264        FlaggedRevs::extraHTMLCacheUpdate( $wikiPage->getTitle() );
265    }
266
267    /**
268     * @inheritDoc
269     * (a) Update flaggedrevs page/tracking tables
270     * (b) Pages with stable versions that use this page will be purged
271     * Note: pages with current versions that use this page should already be purged
272     */
273    public function onArticleUndelete( $title, $create, $comment, $oldPageId, $restoredPages ) {
274        FlaggedRevs::stableVersionUpdates( $title );
275        FlaggedRevs::updateHtmlCaches( $title );
276    }
277
278    /**
279     * @inheritDoc
280     * Update flaggedrevs page/tracking tables
281     */
282    public function onArticleRevisionVisibilitySet( $title, $ids, $visibilityChangeMap ) {
283        $changed = FlaggedRevs::stableVersionUpdates( $title );
284        if ( $changed ) {
285            FlaggedRevs::updateHtmlCaches( $title );
286        }
287    }
288
289    /**
290     * @inheritDoc
291     */
292    public function onParserFirstCallInit( $parser ) {
293        global $wgFlaggedRevsProtection;
294
295        if ( !$wgFlaggedRevsProtection ) {
296            return;
297        }
298
299        $parser->setFunctionHook( 'pendingchangelevel',
300            [ __CLASS__, 'parserPendingChangeLevel' ], Parser::SFH_NO_HASH );
301    }
302
303    /**
304     * @inheritDoc
305     */
306    public function onParserGetVariableValueSwitch( $parser, &$cache, $word, &$ret, $frame ) {
307        global $wgFlaggedRevsProtection;
308        if ( $wgFlaggedRevsProtection && $word === 'pendingchangelevel' ) {
309            $ret = self::parserPendingChangeLevel( $parser );
310            $cache[$word] = $ret;
311        }
312    }
313
314    /**
315     * @inheritDoc
316     */
317    public function onMagicWordwgVariableIDs( &$words ) {
318        global $wgFlaggedRevsProtection;
319
320        if ( !$wgFlaggedRevsProtection ) {
321            return;
322        }
323
324        $words[] = 'pendingchangelevel';
325    }
326
327    /**
328     * @see https://www.mediawiki.org/wiki/Manual:Parser_functions#The_setFunctionHook_hook
329     *
330     * @param Parser $parser
331     * @param string $page
332     * @return string
333     */
334    public static function parserPendingChangeLevel( Parser $parser, $page = '' ) {
335        $title = Title::newFromText( $page );
336        if ( !( $title instanceof Title ) ) {
337            $title = $parser->getTitle();
338        }
339        if ( !FlaggedRevs::inReviewNamespace( $title ) ) {
340            return '';
341        }
342        $page = FlaggableWikiPage::getTitleInstance( $title );
343        if ( !$page->isDataLoaded() && !$parser->incrementExpensiveFunctionCount() ) {
344            return '';
345        }
346        $config = $page->getStabilitySettings();
347        return $config['autoreview'];
348    }
349
350    /**
351     * @inheritDoc
352     * Check page move and patrol permissions for FlaggedRevs
353     */
354    public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
355        if ( $result === false ) {
356            return true; // nothing to do
357        }
358        $services = MediaWikiServices::getInstance();
359        $pm = $services->getPermissionManager();
360        # Don't let users vandalize pages by moving them...
361        if ( $action === 'move' ) {
362            if ( !FlaggedRevs::inReviewNamespace( $title ) || !$title->exists() ) {
363                return true; // extra short-circuit
364            }
365            $flaggedArticle = FlaggableWikiPage::getTitleInstance( $title );
366            # If the draft shows by default anyway, nothing to do...
367            if ( !$flaggedArticle->isStableShownByDefault() ) {
368                return true;
369            }
370            $frev = $flaggedArticle->getStableRev();
371            if ( $frev && !$pm->userHasRight( $user, 'review' ) &&
372                !$pm->userHasRight( $user, 'movestable' )
373            ) {
374                # Allow for only editors/reviewers to move this page
375                $result = false;
376                return false;
377            }
378        # Enforce autoreview/review restrictions
379        } elseif ( $action === 'autoreview' || $action === 'review' ) {
380            # Get autoreview restriction settings...
381            $fa = FlaggableWikiPage::getTitleInstance( $title );
382            $config = $fa->getStabilitySettings();
383            # Convert Sysop -> protect
384            $right = $config['autoreview'];
385            if ( $right === 'sysop' ) {
386                // Backwards compatibility, rewrite sysop -> editprotected
387                $right = 'editprotected';
388            }
389            if ( $right === 'autoconfirmed' ) {
390                // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
391                $right = 'editsemiprotected';
392            }
393            # Check if the user has the required right, if any
394            if ( $right != '' && !$pm->userHasRight( $user, $right ) ) {
395                $result = false;
396                return false;
397            }
398            # Respect page protection to handle cases of "review wars".
399            # If a page is restricted from editing such that a user cannot
400            # edit it, then said user should not be able to review it.
401            foreach ( $services->getRestrictionStore()->getRestrictions( $title, 'edit' ) as $right ) {
402                if ( $right === 'sysop' ) {
403                    // Backwards compatibility, rewrite sysop -> editprotected
404                    $right = 'editprotected';
405                }
406                if ( $right === 'autoconfirmed' ) {
407                    // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
408                    $right = 'editsemiprotected';
409                }
410                if ( $right != '' && !$pm->userHasRight( $user, $right ) ) {
411                    $result = false;
412                    return false;
413                }
414            }
415        }
416        return true;
417    }
418
419    /**
420     * When an edit is made by a user, review it if either:
421     * (a) The user can 'autoreview' and the edit's base revision was checked
422     * (b) The edit is a self-revert to the stable version (by anyone)
423     * (c) The user can 'autoreview' new pages and this edit is a new page
424     * (d) The user can 'review' and the "review pending edits" checkbox was checked
425     *
426     * Note: This hook handler is triggered in a variety of places, not just
427     *       during edits. Example include null revision creation, imports,
428     *       and page moves.
429     *
430     * Note: RC items not inserted yet, RecentChange_save hook does rc_patrolled bit...
431     * @param WikiPage $wikiPage
432     * @param RevisionRecord $revRecord
433     * @param int|false $baseRevId
434     * @param UserIdentity $user
435     */
436    public static function maybeMakeEditReviewed(
437        WikiPage $wikiPage, RevisionRecord $revRecord, $baseRevId, UserIdentity $user
438    ) {
439        global $wgRequest;
440
441        $title = $wikiPage->getTitle(); // convenience
442        # Edit must be non-null, to a reviewable page, with $user set
443        $fa = FlaggableWikiPage::getTitleInstance( $title );
444        $fa->loadPageData( IDBAccessObject::READ_LATEST );
445        if ( !$fa->isReviewable() ) {
446            return;
447        }
448
449        $user = User::newFromIdentity( $user );
450
451        $fa->preloadPreparedEdit( $wikiPage ); // avoid double parse
452        $title->resetArticleID( $revRecord->getPageId() ); // Avoid extra DB hit and lag issues
453        # Get what was just the current revision ID
454        $prevRevId = $revRecord->getParentId();
455        # Get edit timestamp. Existance already validated by \MediaWiki\EditPage\EditPage
456        $editTimestamp = $wgRequest->getVal( 'wpEdittime' );
457        $pm = MediaWikiServices::getInstance()->getPermissionManager();
458        # Is the page manually checked off to be reviewed?
459        if ( $editTimestamp
460            && $wgRequest->getCheck( 'wpReviewEdit' )
461            && $pm->userCan( 'review', $user, $title )
462            && self::editCheckReview( $fa, $revRecord, $user, $editTimestamp )
463        ) {
464            // Reviewed... done!
465            return;
466        }
467        # All cases below require auto-review of edits to be enabled
468        if ( !FlaggedRevs::autoReviewEnabled() ) {
469            // Short-circuit
470            return;
471        }
472        # If a $baseRevId is passed in, the edit is using an old revision's text
473        $isOldRevCopy = (bool)$baseRevId; // null edit or rollback
474        # Get the revision ID the incoming one was based off...
475        $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
476        if ( !$baseRevId && $prevRevId ) {
477            $prevTimestamp = $revisionLookup->getTimestampFromId(
478                $prevRevId,
479                IDBAccessObject::READ_LATEST
480            );
481            # The user just made an edit. The one before that should have
482            # been the current version. If not reflected in wpEdittime, an
483            # edit may have been auto-merged in between, in that case, discard
484            # the baseRevId given from the client.
485            if ( $editTimestamp && $prevTimestamp === $editTimestamp ) {
486                $baseRevId = $wgRequest->getInt( 'baseRevId' );
487            }
488            # If baseRevId not given, assume the previous revision ID (for bots).
489            # For auto-merges, this also occurs since the given ID is ignored.
490            if ( !$baseRevId ) {
491                $baseRevId = $prevRevId;
492            }
493        }
494        $frev = null; // flagged rev this edit was based on
495        $flags = null; // review flags (null => default flags)
496        $srev = $fa->getStableRev();
497        # Case A: this user can auto-review edits. Check if either:
498        # (a) this new revision creates a new page and new page autoreview is enabled
499        # (b) this new revision is based on an old, reviewed, revision
500        if ( $pm->userCan( 'autoreview', $user, $title ) ) {
501            # For rollback/null edits, use the previous ID as the alternate base ID.
502            # Otherwise, use the 'altBaseRevId' parameter passed in by the request.
503            $altBaseRevId = $isOldRevCopy ? $prevRevId : $wgRequest->getInt( 'altBaseRevId' );
504            if ( !$prevRevId ) { // New pages
505                $reviewableNewPage = FlaggedRevs::autoReviewNewPages();
506                $reviewableChange = false;
507            } else { // Edits to existing pages
508                $reviewableNewPage = false; // had previous rev
509                # If a edit was automatically merged, do not trust 'baseRevId' (bug 33481).
510                # Do this by verifying the user-provided edittime against the prior revision.
511                $prevRevTimestamp = $revisionLookup->getTimestampFromId(
512                    $prevRevId,
513                    IDBAccessObject::READ_LATEST
514                );
515                if ( $editTimestamp && $editTimestamp !== $prevRevTimestamp ) {
516                    $baseRevId = $prevRevId;
517                    $altBaseRevId = 0;
518                }
519                # Check if the base revision was reviewed...
520                if ( FlaggedRevs::autoReviewEdits() ) {
521                    $frev = FlaggedRevision::newFromTitle( $title, $baseRevId, IDBAccessObject::READ_LATEST );
522                    if ( !$frev && $altBaseRevId ) {
523                        $frev = FlaggedRevision::newFromTitle( $title, $altBaseRevId, IDBAccessObject::READ_LATEST );
524                    }
525                }
526                $reviewableChange = $frev ||
527                    # Bug 57073: If a user with autoreview returns the page to its last stable
528                    # version, it should be marked stable, regardless of the method used to do so.
529                    ( $srev && $revRecord->getSha1() === $srev->getRevisionRecord()->getSha1() );
530            }
531            # Is this an edit directly to a reviewed version or a new page?
532            if ( $reviewableNewPage || $reviewableChange ) {
533                if ( $isOldRevCopy && $frev ) {
534                    $flags = $frev->getTags(); // null edits & rollbacks keep previous tags
535                }
536                # Review this revision of the page...
537                FlaggedRevs::autoReviewEdit( $fa, $user, $revRecord, $flags );
538            }
539        # Case B: the user cannot autoreview edits. Check if either:
540        # (a) this is a rollback to the stable version
541        # (b) this is a self-reversion to the stable version
542        # These are subcases of making a new revision based on an old, reviewed, revision.
543        } elseif ( FlaggedRevs::autoReviewEdits() && $srev ) {
544            # Check for rollbacks...
545            $reviewableChange = (
546                $isOldRevCopy && // rollback or null edit
547                $baseRevId != $prevRevId && // not a null edit
548                $baseRevId == $srev->getRevId() && // restored stable rev
549                $pm->userCan( 'autoreviewrestore', $user, $title )
550            );
551            # Check for self-reversions (checks text hashes)...
552            if ( !$reviewableChange ) {
553                $reviewableChange = self::isSelfRevertToStable( $revRecord, $srev, $baseRevId, $user );
554            }
555            # Is this a rollback or self-reversion to the stable rev?
556            if ( $reviewableChange ) {
557                $flags = $srev->getTags(); // use old tags
558                # Review this revision of the page...
559                FlaggedRevs::autoReviewEdit( $fa, $user, $revRecord, $flags );
560            }
561        }
562    }
563
564    /**
565     * Review $rev if $editTimestamp matches the previous revision's timestamp.
566     * Otherwise, review the revision that has $editTimestamp as its timestamp value.
567     * @param WikiPage $wikiPage
568     * @param RevisionRecord $revRecord
569     * @param User $user
570     * @param string $editTimestamp
571     * @return bool
572     */
573    private static function editCheckReview(
574        WikiPage $wikiPage, $revRecord, $user, $editTimestamp
575    ) {
576        $prevTimestamp = null;
577        $prevRevId = $revRecord->getParentId(); // id for revision before $revRecord
578        # Check wpEdittime against the former current rev for verification
579        if ( $prevRevId ) {
580            $prevTimestamp = MediaWikiServices::getInstance()
581                ->getRevisionLookup()
582                ->getTimestampFromId( $prevRevId, IDBAccessObject::READ_LATEST );
583        }
584        # Was $revRecord an edit to an existing page?
585        if ( $prevTimestamp && $prevRevId ) {
586            # Check wpEdittime against the former current revision's time.
587            # If an edit was auto-merged in between, then the new revision
588            # has content different than what the user expected. However, if
589            # the auto-merged edit was reviewed, then assume that it's OK.
590            if ( $editTimestamp != $prevTimestamp
591                && !FlaggedRevision::revIsFlagged( $prevRevId, IDBAccessObject::READ_LATEST )
592            ) {
593                return false; // not flagged?
594            }
595        }
596        $flags = null;
597        # Review this revision of the page...
598        return FlaggedRevs::autoReviewEdit( $wikiPage, $user, $revRecord, $flags, false /* manual */ );
599    }
600
601    /**
602     * Check if a user reverted himself to the stable version
603     * @param RevisionRecord $revRecord
604     * @param FlaggedRevision $srev
605     * @param int $baseRevId
606     * @param User $user
607     * @return bool
608     */
609    private static function isSelfRevertToStable(
610        RevisionRecord $revRecord,
611        $srev,
612        $baseRevId,
613        $user
614    ) {
615        if ( !$srev || $baseRevId != $srev->getRevId() ) {
616            return false; // user reports they are not the same
617        }
618        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
619
620        # Such a revert requires 1+ revs between it and the stable
621        $revWhere = ActorMigration::newMigration()->getWhere( $dbw, 'rev_user', $user );
622        $revertedRevs = (bool)$dbw->newSelectQueryBuilder()
623            ->select( '1' )
624            ->from( 'revision' )
625            ->tables( $revWhere['tables'] )
626            ->where( [
627                'rev_page' => $revRecord->getPageId(),
628                $dbw->expr( 'rev_id', '>', intval( $baseRevId ) ), // stable rev
629                $dbw->expr( 'rev_id', '<', intval( $revRecord->getId() ) ), // this rev
630                $revWhere['conds']
631            ] )
632            ->joinConds( $revWhere['joins'] )
633            ->caller( __METHOD__ )
634            ->fetchField();
635        if ( !$revertedRevs ) {
636            return false; // can't be a revert
637        }
638        # Check that this user is ONLY reverting his/herself.
639        $otherUsers = (bool)$dbw->newSelectQueryBuilder()
640            ->select( '1' )
641            ->from( 'revision' )
642            ->tables( $revWhere['tables'] )
643            ->where( [
644                'rev_page' => $revRecord->getPageId(),
645                $dbw->expr( 'rev_id', '>', intval( $baseRevId ) ),
646                'NOT( ' . $revWhere['conds'] . ' )'
647            ] )
648            ->joinConds( $revWhere['joins'] )
649            ->caller( __METHOD__ )
650            ->fetchField();
651        if ( $otherUsers ) {
652            return false; // only looking for self-reverts
653        }
654        # Confirm the text because we can't trust this user.
655        return ( $revRecord->getSha1() === $srev->getRevisionRecord()->getSha1() );
656    }
657
658    /**
659     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageSaveComplete
660     *
661     * When an user makes a null-edit we sometimes want to review it...
662     * (a) Null undo or rollback
663     * (b) Null edit with review box checked
664     * Note: called after edit ops are finished
665     *
666     * @param WikiPage $wikiPage
667     * @param UserIdentity $userIdentity
668     * @param string $summary
669     * @param int $flags
670     * @param RevisionRecord $revisionRecord
671     * @param EditResult $editResult
672     */
673    public static function maybeNullEditReview(
674        WikiPage $wikiPage,
675        UserIdentity $userIdentity,
676        string $summary,
677        int $flags,
678        RevisionRecord $revisionRecord,
679        EditResult $editResult
680    ) {
681        global $wgRequest;
682        if ( !$editResult->isNullEdit() ) {
683            // Not a null edit
684            return;
685        }
686
687        $baseId = $editResult->getOriginalRevisionId();
688
689        # Rollback/undo or box checked
690        $reviewEdit = $wgRequest->getCheck( 'wpReviewEdit' );
691        if ( !$baseId && !$reviewEdit ) {
692            // Short-circuit
693            return;
694        }
695
696        $title = $wikiPage->getTitle(); // convenience
697        $fa = FlaggableWikiPage::getTitleInstance( $title );
698        $fa->loadPageData( IDBAccessObject::READ_LATEST );
699        if ( !$fa->isReviewable() ) {
700            // Page is not reviewable
701            return;
702        }
703        # Get the current revision ID
704        $revLookup = MediaWikiServices::getInstance()->getRevisionLookup();
705        $revRecord = $revLookup->getRevisionByTitle( $title, 0, IDBAccessObject::READ_LATEST );
706        if ( !$revRecord ) {
707            return;
708        }
709
710        $flags = null;
711        $user = User::newFromIdentity( $userIdentity );
712        # Is this a rollback/undo that didn't change anything?
713        if ( $baseId > 0 ) {
714            $frev = FlaggedRevision::newFromTitle( $title, $baseId ); // base rev of null edit
715            $pRevRecord = $revLookup->getRevisionById( $revRecord->getParentId() ); // current rev parent
716            $revIsNull = ( $pRevRecord && $pRevRecord->hasSameContent( $revRecord ) );
717            # Was the edit that we tried to revert to reviewed?
718            # We avoid auto-reviewing null edits to avoid confusion (bug 28476).
719            if ( $frev && !$revIsNull ) {
720                # Review this revision of the page...
721                $ok = FlaggedRevs::autoReviewEdit( $wikiPage, $user, $revRecord, $flags );
722                if ( $ok ) {
723                    FlaggedRevs::markRevisionPatrolled( $revRecord ); // reviewed -> patrolled
724                    FlaggedRevs::extraHTMLCacheUpdate( $title );
725                    return;
726                }
727            }
728        }
729        # Get edit timestamp, it must exist.
730        $editTimestamp = $wgRequest->getVal( 'wpEdittime' );
731        # Is the page checked off to be reviewed?
732        if ( $editTimestamp && $reviewEdit && MediaWikiServices::getInstance()
733                ->getPermissionManager()->userCan( 'review', $user, $title )
734        ) {
735            # Check wpEdittime against current revision's time.
736            # If an edit was auto-merged in between, review only up to what
737            # was the current rev when this user started editing the page.
738            if ( $revRecord->getTimestamp() != $editTimestamp ) {
739                $revRecord = $revLookup->getRevisionByTimestamp(
740                    $title,
741                    $editTimestamp,
742                    IDBAccessObject::READ_LATEST
743                );
744                if ( !$revRecord ) {
745                    // Deleted?
746                    return;
747                }
748            }
749            # Review this revision of the page...
750            $ok = FlaggedRevs::autoReviewEdit( $wikiPage, $user, $revRecord, $flags, false /* manual */ );
751            if ( $ok ) {
752                FlaggedRevs::markRevisionPatrolled( $revRecord ); // reviewed -> patrolled
753                FlaggedRevs::extraHTMLCacheUpdate( $title );
754            }
755        }
756    }
757
758    /**
759     * @inheritDoc
760     * Mark auto-reviewed edits as patrolled
761     */
762    public function onRecentChange_save( $rc ) {
763        if ( !$rc->getAttribute( 'rc_this_oldid' ) ) {
764            return;
765        }
766        // don't autopatrol autoreviewed edits when using pending changes,
767        // otherwise edits by autoreviewed users on pending changes protected pages would be
768        // autopatrolled and could not be checked through RC patrol as on regular pages
769        if ( FlaggedRevs::useOnlyIfProtected() ) {
770            return;
771        }
772        $fa = FlaggableWikiPage::getTitleInstance( $rc->getTitle() );
773        $fa->loadPageData( IDBAccessObject::READ_LATEST );
774        // Is the page reviewable?
775        if ( $fa->isReviewable() ) {
776            $revId = $rc->getAttribute( 'rc_this_oldid' );
777            // If the edit we just made was reviewed, then it's the stable rev
778            $frev = FlaggedRevision::newFromTitle( $rc->getTitle(), $revId, IDBAccessObject::READ_LATEST );
779            // Reviewed => patrolled
780            if ( $frev ) {
781                DeferredUpdates::addCallableUpdate( static function () use ( $rc, $frev ) {
782                    RevisionReviewForm::updateRecentChanges( $rc, 'patrol', $frev );
783                } );
784                $rcAttribs = $rc->getAttributes();
785                $rcAttribs['rc_patrolled'] = 1; // make sure irc/email notifs know status
786                $rc->setAttribs( $rcAttribs );
787            }
788        }
789    }
790
791    private function maybeIncrementReverts(
792        WikiPage $wikiPage, RevisionRecord $revRecord, EditResult $editResult, UserIdentity $user
793    ) {
794        $undid = $editResult->getOldestRevertedRevisionId();
795
796        # Was this an edit by an auto-sighter that undid another edit?
797        if ( !( $undid && $this->permissionManager->userHasRight( $user, 'autoreview' ) ) ) {
798            return;
799        }
800
801        // Note: $rev->getTitle() might be undefined (no rev id?)
802        $badRevRecord = $this->revisionLookup->getRevisionByTitle( $wikiPage->getTitle(), $undid );
803        if ( !( $badRevRecord && $badRevRecord->getUser( RevisionRecord::RAW ) ) ) {
804            return;
805        }
806
807        $revRecordUser = $revRecord->getUser( RevisionRecord::RAW );
808        $badRevRecordUser = $badRevRecord->getUser( RevisionRecord::RAW );
809        if ( $this->userIdentityUtils->isNamed( $badRevRecordUser )
810            && !$badRevRecordUser->equals( $revRecordUser ) // no self-reverts
811        ) {
812            FRUserCounters::incCount( $badRevRecordUser->getId(), 'revertedEdits' );
813        }
814    }
815
816    /**
817     * Get query data for making efficient queries based on rev_user and
818     * rev_timestamp in an actor table world.
819     * @param IReadableDatabase $dbr
820     * @param User $user
821     * @return array[]
822     */
823    private static function getQueryData( IReadableDatabase $dbr, $user ) {
824        $revWhere = ActorMigration::newMigration()->getWhere( $dbr, 'rev_user', $user );
825        $queryData = [];
826        foreach ( $revWhere['orconds'] as $key => $cond ) {
827            if ( $key === 'actor' ) {
828                $data = [
829                    'tables' => [ 'revision' ] + $revWhere['tables'],
830                    'tsField' => 'revactor_timestamp',
831                    'cond' => $cond,
832                    'joins' => $revWhere['joins'],
833                    'useIndex' => [ 'temp_rev_user' => 'actor_timestamp' ],
834                ];
835                $data['joins']['temp_rev_user'][0] = 'JOIN';
836            } elseif ( $key === 'username' ) {
837                // Ignore this, shouldn't happen
838                continue;
839            } else { // future migration from revision_actor_temp to rev_actor
840                $data = [
841                    'tables' => [ 'revision' ],
842                    'tsField' => 'rev_timestamp',
843                    'cond' => $cond,
844                    'joins' => [],
845                    'useIndex' => [ 'revision' => 'rev_actor_timestamp' ],
846                ];
847            }
848            $queryData[] = $data;
849        }
850        return $queryData;
851    }
852
853    /**
854     * Check if a user meets the edit spacing requirements.
855     * If the user does not, return a *lower bound* number of seconds
856     * that must elapse for it to be possible for the user to meet them.
857     * @param User $user
858     * @param int $spacingReq days apart (of edit points)
859     * @param int $pointsReq number of edit points
860     * @return true|int True if passed, int seconds on failure
861     */
862    private static function editSpacingCheck( User $user, $spacingReq, $pointsReq ) {
863        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
864
865        $queryData = self::getQueryData( $dbr, $user );
866
867        $benchmarks = 0; // actual edit points
868        # Convert days to seconds...
869        $spacingReq = $spacingReq * 24 * 3600;
870        # Check the oldest edit
871        $lower = false;
872        foreach ( $queryData as $data ) {
873            $ts = $dbr->newSelectQueryBuilder()
874                ->tables( $data['tables'] )
875                ->field( $data['tsField'] )
876                ->where( $data['cond'] )
877                ->orderby( $data['tsField'] )
878                ->useIndex( $data['useIndex'] )
879                ->joinConds( $data['joins'] )
880                ->caller( __METHOD__ )
881                ->fetchField();
882            $lower = $lower && $ts ? min( $lower, $ts ) : ( $lower ?: $ts );
883        }
884        # Recursively check for an edit $spacingReq seconds later, until we are done.
885        if ( $lower ) {
886            $benchmarks++; // the first edit above counts
887            while ( $lower && $benchmarks < $pointsReq ) {
888                $next = (int)wfTimestamp( TS_UNIX, $lower ) + $spacingReq;
889                $lower = false;
890                foreach ( $queryData as $data ) {
891                    $ts = $dbr->newSelectQueryBuilder()
892                        ->tables( $data['tables'] )
893                        ->field( $data['tsField'] )
894                        ->where( $data['cond'] )
895                        ->andWhere( $dbr->expr( $data['tsField'], '>', $dbr->timestamp( $next ) ) )
896                        ->orderBy( $data['tsField'] )
897                        ->useIndex( $data['useIndex'] )
898                        ->joinConds( $data['joins'] )
899                        ->caller( __METHOD__ )
900                        ->fetchField();
901                    $lower = $lower && $ts ? min( $lower, $ts ) : ( $lower ?: $ts );
902                }
903                if ( $lower !== false ) {
904                    $benchmarks++;
905                }
906            }
907        }
908        if ( $benchmarks >= $pointsReq ) {
909            return true;
910        } else {
911            // Does not add time for the last required edit point; it could be a
912            // fraction of $spacingReq depending on the last actual edit point time.
913            return ( $spacingReq * ( $pointsReq - $benchmarks - 1 ) );
914        }
915    }
916
917    /**
918     * Check if a user has enough implicitly reviewed edits (before stable version)
919     * @param User $user
920     * @param int $editsReq
921     * @param int $seconds
922     * @return bool
923     */
924    private static function reviewedEditsCheck( User $user, $editsReq, $seconds = 0 ) {
925        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
926
927        $queryData = self::getQueryData( $dbr, $user );
928        // Get cutoff timestamp (excludes edits that are too recent)
929        foreach ( $queryData as $k => $data ) {
930            $queryData[$k]['conds'] = [
931                $data['cond'],
932                $dbr->expr( $data['tsField'], '<', $dbr->timestamp( time() - $seconds ) ),
933            ];
934        }
935        // Get the lower cutoff to avoid scanning over many rows.
936        // Users with many revisions will only have the last 10k inspected.
937        $lowCutoff = false;
938        if ( $user->getEditCount() > 10000 ) {
939            foreach ( $queryData as $data ) {
940                $lowCutoff = max( $lowCutoff, $dbr->newSelectQueryBuilder()
941                    ->tables( $data['tables'] )
942                    ->field( $data['tsField'] )
943                    ->where( $data['conds'] )
944                    ->orderBy( $data['tsField'], SelectQueryBuilder::SORT_DESC )
945                    ->offset( 9999 )
946                    ->joinConds( $data['joins'] )
947                    ->caller( __METHOD__ )
948                    ->fetchField()
949                );
950            }
951        }
952        $lowCutoff = $lowCutoff ?: 1; // default to UNIX 1970
953        // Get revs from pages that have a reviewed rev of equal or higher timestamp
954        $ct = 0;
955        foreach ( $queryData as $data ) {
956            if ( $ct >= $editsReq ) {
957                break;
958            }
959            $res = $dbr->newSelectQueryBuilder()
960                ->select( '1' )
961                ->tables( $data['tables'] )
962                ->join( 'flaggedpages', null, 'fp_page_id = rev_page' )
963                ->where( $data['conds'] )
964                ->andWhere( [
965                        // bug 15515
966                        $dbr->expr( 'fp_pending_since', '=', null )
967                            ->or( 'fp_pending_since', '>', new RawSQLValue( $data['tsField'] ) ),
968                        // Avoid too much scanning
969                        $dbr->expr( $data['tsField'], '>', $dbr->timestamp( $lowCutoff ) )
970                ] )
971                ->limit( $editsReq - $ct )
972                ->joinConds( $data['joins'] )
973                ->caller( __METHOD__ )
974                ->fetchResultSet();
975            $ct += $res->numRows();
976        }
977        return ( $ct >= $editsReq );
978    }
979
980    /**
981     * Checks if $user was previously blocked since $cutoff_unixtime
982     * @param User $user
983     * @param IReadableDatabase $db
984     * @param int $cutoff_unixtime = 0
985     * @return bool
986     */
987    private static function wasPreviouslyBlocked(
988        User $user,
989        IReadableDatabase $db,
990        $cutoff_unixtime = 0
991    ) {
992        $conds = [
993            'log_namespace' => NS_USER,
994            'log_title'     => $user->getUserPage()->getDBkey(),
995            'log_type'      => 'block',
996            'log_action'    => 'block'
997        ];
998        if ( $cutoff_unixtime > 0 ) {
999            # Hint to improve NS,title,timestamp INDEX use
1000            $conds[] = $db->expr( 'log_timestamp', '>=', $db->timestamp( $cutoff_unixtime ) );
1001        }
1002        return (bool)$db->newSelectQueryBuilder()
1003            ->select( '1' )
1004            ->from( 'logging' )
1005            ->where( $conds )
1006            ->caller( __METHOD__ )
1007            ->fetchField();
1008    }
1009
1010    /**
1011     * @param int $userId
1012     * @param int $seconds
1013     * @param int $limit
1014     * @return int
1015     */
1016    private static function recentEditCount( $userId, $seconds, $limit ) {
1017        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1018
1019        $queryData = self::getQueryData( $dbr, User::newFromId( $userId ) );
1020        # Get cutoff timestamp (edits that are too recent)
1021        $cutoff = $dbr->timestamp( time() - $seconds );
1022        # Check all recent edits...
1023        $ct = 0;
1024        foreach ( $queryData as $data ) {
1025            if ( $ct > $limit ) {
1026                break;
1027            }
1028            $res = $dbr->newSelectQueryBuilder()
1029                ->select( '1' )
1030                ->tables( $data['tables'] )
1031                ->where( $data['cond'] )
1032                ->andWhere( $dbr->expr( $data['tsField'], '>', $cutoff ) )
1033                ->limit( $limit + 1 - $ct )
1034                ->joinConds( $data['joins'] )
1035                ->caller( __METHOD__ )
1036                ->fetchResultSet();
1037            $ct += $res->numRows();
1038        }
1039        return $ct;
1040    }
1041
1042    /**
1043     * @param int $userId
1044     * @param int $seconds
1045     * @param int $limit
1046     * @return int
1047     */
1048    private static function recentContentEditCount( $userId, $seconds, $limit ) {
1049        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1050
1051        $queryData = self::getQueryData( $dbr, User::newFromId( $userId ) );
1052        # Get cutoff timestamp (edits that are too recent)
1053        $cutoff = $dbr->timestamp( time() - $seconds );
1054        # Check all recent content edits...
1055        $ct = 0;
1056        $contentNamespaces = MediaWikiServices::getInstance()
1057            ->getNamespaceInfo()
1058            ->getContentNamespaces();
1059        foreach ( $queryData as $data ) {
1060            if ( $ct > $limit ) {
1061                break;
1062            }
1063            $res = $dbr->newSelectQueryBuilder()
1064                ->select( '1' )
1065                ->tables( $data['tables'] )
1066                ->join( 'page', null, 'rev_page = page_id' )
1067                ->where( [
1068                    $data['cond'],
1069                    $dbr->expr( $data['tsField'], '>', $cutoff ),
1070                    'page_namespace' => $contentNamespaces
1071                ] )
1072                ->limit( $limit + 1 - $ct )
1073                ->useIndex( $data['useIndex'] )
1074                ->joinConds( $data['joins'] )
1075                ->caller( __METHOD__ )
1076                ->fetchResultSet();
1077            $ct += $res->numRows();
1078        }
1079        return $ct;
1080    }
1081
1082    /**
1083     * @inheritDoc
1084     * Grant 'autoreview' rights to users with the 'bot' right
1085     */
1086    public function onUserGetRights( $user, &$rights ) {
1087        # Make sure bots always have the 'autoreview' right
1088        if ( in_array( 'bot', $rights ) && !in_array( 'autoreview', $rights ) ) {
1089            $rights[] = 'autoreview';
1090        }
1091    }
1092
1093    /**
1094     * @inheritDoc
1095     * Mark the edit as autoreviewed if needed.
1096     * This must happen in this hook, and not in onPageSaveComplete(), for two reasons:
1097     * - onBeforeRevertedTagUpdate() implementation relies on it happening first (T361918)
1098     * - It must also be done for null revisions created during some actions (T361940, T361960)
1099     */
1100    public function onRevisionFromEditComplete(
1101        $wikiPage, $revRecord, $baseRevId, $user, &$tags
1102    ) {
1103        self::maybeMakeEditReviewed( $wikiPage, $revRecord, $baseRevId, $user );
1104    }
1105
1106    /**
1107     * @inheritDoc
1108     * Callback that autopromotes user according to the setting in
1109     * $wgFlaggedRevsAutopromote. This also handles user stats tallies.
1110     */
1111    public function onPageSaveComplete(
1112        $wikiPage,
1113        $userIdentity,
1114        $summary,
1115        $flags,
1116        $revisionRecord,
1117        $editResult
1118    ) {
1119        self::maybeIncrementReverts( $wikiPage, $revisionRecord, $editResult, $userIdentity );
1120
1121        self::maybeNullEditReview( $wikiPage, $userIdentity, $summary, $flags, $revisionRecord, $editResult );
1122
1123        # Ignore null edits, edits by IP users or temporary users, and MW role account edits
1124        if ( $editResult->isNullEdit() ||
1125            !$this->userIdentityUtils->isNamed( $userIdentity ) ||
1126            !$this->userNameUtils->isUsable( $userIdentity->getName() )
1127        ) {
1128            return;
1129        }
1130
1131        # No sense in running counters if nothing uses them
1132        if ( !( $this->config->get( 'FlaggedRevsAutopromote' ) || $this->config->get( 'FlaggedRevsAutoconfirm' ) ) ) {
1133            return;
1134        }
1135
1136        $userId = $userIdentity->getId();
1137        DeferredUpdates::addCallableUpdate( static function () use ( $userId, $wikiPage, $summary ) {
1138            $p = FRUserCounters::getUserParams( $userId, IDBAccessObject::READ_EXCLUSIVE );
1139            $changed = FRUserCounters::updateUserParams( $p, $wikiPage->getTitle(), $summary );
1140            if ( $changed ) {
1141                FRUserCounters::saveUserParams( $userId, $p ); // save any updates
1142            }
1143        } );
1144    }
1145
1146    /**
1147     * @inheritDoc
1148     * Check an autopromote condition that is defined by FlaggedRevs
1149     *
1150     * Note: some unobtrusive caching is used to avoid DB hits.
1151     */
1152    public function onAutopromoteCondition( $cond, $params, $user, &$result ) {
1153        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1154        switch ( $cond ) {
1155            case APCOND_FR_EDITSUMMARYCOUNT:
1156                $p = FRUserCounters::getParams( $user );
1157                $result = ( $p && $p['editComments'] >= $params[0] );
1158                break;
1159            case APCOND_FR_NEVERBLOCKED:
1160                if ( $user->getBlock() ) {
1161                    $result = false; // failed
1162                } else {
1163                    // See T262970 for an explanation of this
1164                    $hasPriorBlock = $cache->getWithSetCallback(
1165                        $cache->makeKey( 'flaggedrevs-autopromote-notblocked', $user->getId() ),
1166                        $cache::TTL_SECOND,
1167                        function ( $oldValue, &$ttl, array &$setOpts, $oldAsOf ) use ( $user ) {
1168                            // Once the user is blocked once, this condition will always
1169                            // fail. To avoid running queries again, if the old cached value
1170                            // is `priorBlock`, just return that immediately.
1171                            if ( $oldValue === 'priorBlock' ) {
1172                                return 'priorBlock';
1173                            }
1174
1175                            // Since the user had no block prior to the last time
1176                            // the value was cached, we only need to check for
1177                            // blocks since then. If there was no prior cached
1178                            // value, check for all time (since time 0).
1179                            // The time of the last check is itself the cached
1180                            // value.
1181                            $startingTimestamp = is_int( $oldValue ) ? $oldValue : 0;
1182
1183                            // If the user still hasn't been blocked, we will
1184                            // update the cached value to be the current timestamp
1185                            $newTimestamp = time();
1186
1187                            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1188
1189                            $setOpts += Database::getCacheSetOptions( $dbr );
1190
1191                            $hasPriorBlock = self::wasPreviouslyBlocked(
1192                                $user,
1193                                $dbr,
1194                                $startingTimestamp
1195                            );
1196                            if ( $hasPriorBlock ) {
1197                                // Store 'priorBlock' so that we can
1198                                // skip everything in the future
1199                                return 'priorBlock';
1200                            }
1201
1202                            // Store the current time, so that future
1203                            // checks don't query everything
1204                            return $newTimestamp;
1205                        },
1206                        [ 'staleTTL' => $cache::TTL_WEEK ]
1207                    );
1208                    $result = ( $hasPriorBlock !== 'priorBlock' );
1209                }
1210                break;
1211            case APCOND_FR_UNIQUEPAGECOUNT:
1212                $p = FRUserCounters::getParams( $user );
1213                $result = ( $p && count( $p['uniqueContentPages'] ) >= $params[0] );
1214                break;
1215            case APCOND_FR_EDITSPACING:
1216                $key = $cache->makeKey(
1217                    'flaggedrevs-autopromote-editspacing',
1218                    $user->getId(),
1219                    $params[0],
1220                    $params[1]
1221                );
1222                $val = $cache->get( $key );
1223                if ( $val === 'true' ) {
1224                    $result = true; // passed
1225                } elseif ( $val === 'false' ) {
1226                    $result = false; // failed
1227                } else {
1228                    # Hit the DB only if the result is not cached...
1229                    $pass = self::editSpacingCheck( $user, $params[0], $params[1] );
1230                    # Make a key to store the results
1231                    if ( $pass === true ) {
1232                        $cache->set( $key, 'true', 2 * $cache::TTL_WEEK );
1233                    } else {
1234                        $cache->set( $key, 'false', $pass /* wait time */ );
1235                    }
1236                    $result = ( $pass === true );
1237                }
1238                break;
1239            case APCOND_FR_EDITCOUNT:
1240                # $maxNew is the *most* edits that can be too recent
1241                $maxNew = $user->getEditCount() - $params[0];
1242                if ( $maxNew < 0 ) {
1243                    $result = false; // doesn't meet count even *with* recent edits
1244                } elseif ( $params[1] <= 0 ) {
1245                    $result = true; // passed; we aren't excluding any recent edits
1246                } else {
1247                    # Check all recent edits...
1248                    $n = self::recentEditCount( $user->getId(), $params[1], $maxNew );
1249                    $result = ( $n <= $maxNew );
1250                }
1251                break;
1252            case APCOND_FR_CONTENTEDITCOUNT:
1253                $p = FRUserCounters::getParams( $user );
1254                if ( !$p ) {
1255                    $result = false;
1256                } else {
1257                    # $maxNew is the *most* edits that can be too recent
1258                    $maxNew = $p['totalContentEdits'] - $params[0];
1259                    if ( $maxNew < 0 ) {
1260                        $result = false; // doesn't meet count even *with* recent edits
1261                    } elseif ( $params[1] <= 0 ) {
1262                        $result = true; // passed; we aren't excluding any recent edits
1263                    } else {
1264                        # Check all recent content edits...
1265                        $n = self::recentContentEditCount( $user->getId(), $params[1], $maxNew );
1266                        $result = ( $n <= $maxNew );
1267                    }
1268                }
1269                break;
1270            case APCOND_FR_CHECKEDEDITCOUNT:
1271                $key = $cache->makeKey(
1272                    'flaggedrevs-autopromote-reviewededits',
1273                    $user->getId(),
1274                    $params[0],
1275                    $params[1]
1276                );
1277                $val = $cache->get( $key );
1278                if ( $val === 'true' ) {
1279                    $result = true; // passed
1280                } elseif ( $val === 'false' ) {
1281                    $result = false; // failed
1282                } else {
1283                    # Hit the DB only if the result is not cached...
1284                    $result = self::reviewedEditsCheck( $user, $params[0], $params[1] );
1285                    if ( $result ) {
1286                        $cache->set( $key, 'true', $cache::TTL_WEEK );
1287                    } else {
1288                        $cache->set( $key, 'false', $cache::TTL_HOUR ); // briefly cache
1289                    }
1290                }
1291                break;
1292            case APCOND_FR_USERPAGEBYTES:
1293                $result = ( !$params[0] || $user->getUserPage()->getLength() >= $params[0] );
1294                break;
1295            case APCOND_FR_MAXREVERTEDEDITRATIO:
1296                $p = FRUserCounters::getParams( $user );
1297                $result = ( $p && $params[0] * $user->getEditCount() >= $p['revertedEdits'] );
1298                break;
1299            case APCOND_FR_NEVERDEMOTED: // b/c
1300                $p = FRUserCounters::getParams( $user );
1301                $result = $p !== null && !( $p['demoted'] ?? false );
1302                break;
1303        }
1304    }
1305
1306    /**
1307     * @inheritDoc
1308     * Set session key.
1309     */
1310    public function onUserLoadAfterLoadFromSession( $user ) {
1311        global $wgRequest;
1312        if ( $user->isRegistered() && MediaWikiServices::getInstance()->getPermissionManager()
1313                ->userHasRight( $user, 'review' )
1314        ) {
1315            $key = $wgRequest->getSessionData( 'wsFlaggedRevsKey' );
1316            if ( $key === null ) { // should catch login
1317                $key = MWCryptRand::generateHex( 32 );
1318                // Temporary secret key attached to this session
1319                $wgRequest->setSessionData( 'wsFlaggedRevsKey', $key );
1320            }
1321        }
1322    }
1323
1324    /**
1325     * @inheritDoc
1326     */
1327    public function onWikiExporter__dumpStableQuery( &$tables, &$opts, &$join ) {
1328        $namespaces = FlaggedRevs::getReviewNamespaces();
1329        if ( $namespaces ) {
1330            $tables[] = 'flaggedpages';
1331            $opts['ORDER BY'] = 'fp_page_id ASC';
1332            $opts['USE INDEX'] = [ 'flaggedpages' => 'PRIMARY' ];
1333            $join['page'] = [ 'INNER JOIN',
1334                [ 'page_id = fp_page_id', 'page_namespace' => $namespaces ]
1335            ];
1336            $join['revision'] = [ 'INNER JOIN',
1337                [ 'rev_page = fp_page_id', 'rev_id = fp_stable' ] ];
1338        }
1339    }
1340
1341    /**
1342     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GoogleNewsSitemap::Query
1343     *
1344     * @param array $params
1345     * @param array &$joins
1346     * @param array &$conditions
1347     * @param array &$tables
1348     */
1349    public static function gnsmQueryModifier(
1350        array $params, array &$joins, array &$conditions, array &$tables
1351    ) {
1352        $filterSet = [ GoogleNewsSitemap::OPT_ONLY => true,
1353            GoogleNewsSitemap::OPT_EXCLUDE => true
1354        ];
1355        # Either involves the same JOIN here...
1356        if ( isset( $filterSet[ $params['stable'] ] ) || isset( $filterSet[ $params['quality'] ] ) ) {
1357            $tables[] = 'flaggedpages';
1358            $joins['flaggedpages'] = [ 'LEFT JOIN', 'page_id = fp_page_id' ];
1359        }
1360
1361        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1362        switch ( $params['stable'] ) {
1363            case GoogleNewsSitemap::OPT_ONLY:
1364                $conditions[] = $dbr->expr( 'fp_stable', '!=', null );
1365                break;
1366            case GoogleNewsSitemap::OPT_EXCLUDE:
1367                $conditions['fp_stable'] = null;
1368                break;
1369        }
1370
1371        switch ( $params['quality'] ) {
1372            case GoogleNewsSitemap::OPT_ONLY:
1373                $conditions[] = $dbr->expr( 'fp_quality', '>=', 1 );
1374                break;
1375            case GoogleNewsSitemap::OPT_EXCLUDE:
1376                $conditions[] = $dbr->expr( 'fp_quality', '=', 0 )->or( 'fp_quality', '=', null );
1377                break;
1378        }
1379    }
1380
1381    /**
1382     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EchoGetDefaultNotifiedUsers
1383     *
1384     * This should go once we can remove all Echo-specific code for reverts,
1385     * see: T153570
1386     * @param Event $event Event to get implicitly subscribed users for
1387     * @param User[] &$users Array to append implicitly subscribed users to.
1388     */
1389    public static function onEchoGetDefaultNotifiedUsers( $event, &$users ) {
1390        $extra = $event->getExtra();
1391        if ( $event->getType() == 'reverted' && $extra['method'] == 'flaggedrevs-reject' ) {
1392            foreach ( $extra['reverted-users-ids'] as $userId ) {
1393                $users[$userId] = User::newFromId( intval( $userId ) );
1394            }
1395        }
1396    }
1397
1398    /**
1399     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserMergeAccountFields
1400     *
1401     * @param array &$updateFields
1402     */
1403    public static function onUserMergeAccountFields( array &$updateFields ) {
1404        $updateFields[] = [ 'flaggedrevs', 'fr_user' ];
1405    }
1406
1407    /**
1408     * @see https://www.mediawiki.org/wiki/Manual:Hooks/MergeAccountFromTo
1409     *
1410     * @param User $oldUser
1411     * @param User $newUser
1412     */
1413    public static function onMergeAccountFromTo( User $oldUser, User $newUser ) {
1414        if ( $newUser->isRegistered() ) {
1415            FRUserCounters::mergeUserParams( $oldUser, $newUser );
1416        }
1417    }
1418
1419    /**
1420     * @see https://www.mediawiki.org/wiki/Manual:Hooks/DeleteAccount
1421     *
1422     * @param User $oldUser
1423     */
1424    public static function onDeleteAccount( User $oldUser ) {
1425        FRUserCounters::deleteUserParams( $oldUser );
1426    }
1427
1428    /**
1429     * @inheritDoc
1430     * As the hook is called after saving the edit (in a deferred update), we have already
1431     * figured out whether the edit should be autoreviewed or not (see: maybeMakeEditReviewed
1432     * method). This hook just checks whether the edit is marked as reviewed or not.
1433     */
1434    public function onBeforeRevertedTagUpdate(
1435        $wikiPage,
1436        $user,
1437        $summary,
1438        $flags,
1439        $revisionRecord,
1440        $editResult,
1441        &$approved
1442    ): void {
1443        $title = $wikiPage->getTitle();
1444        $fPage = FlaggableWikiPage::getTitleInstance( $title );
1445        $fPage->loadPageData( IDBAccessObject::READ_LATEST );
1446        if ( !$fPage->isReviewable() ) {
1447            // The page is not reviewable
1448            return;
1449        }
1450
1451        // Check if the revision was approved
1452        $flaggedRev = FlaggedRevision::newFromTitle(
1453            $wikiPage->getTitle(),