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