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