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