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