Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 674 |
|
0.00% |
0 / 38 |
CRAP | |
0.00% |
0 / 1 |
FlaggedRevsHooks | |
0.00% |
0 / 674 |
|
0.00% |
0 / 38 |
49062 | |
0.00% |
0 / 1 |
onRegistration | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
onMediaWikiServices | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onRevisionUndeleted | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
onArticleMergeComplete | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
6 | |||
onPageMoveComplete | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
72 | |||
onRevisionDataUpdates | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
onArticleDeleteComplete | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
onArticleUndelete | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
onArticleRevisionVisibilitySet | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
onParserFirstCallInit | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
onParserGetVariableValueSwitch | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
onMagicWordwgVariableIDs | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
parserPendingChangeLevel | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
onGetUserPermissionsErrors | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
420 | |||
maybeMakeEditReviewed | |
0.00% |
0 / 70 |
|
0.00% |
0 / 1 |
1122 | |||
editCheckReview | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
isSelfRevertToStable | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
30 | |||
maybeNullEditReview | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
306 | |||
onRecentChange_save | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
maybeIncrementReverts | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
56 | |||
getQueryData | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
20 | |||
editSpacingCheck | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
210 | |||
reviewedEditsCheck | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
56 | |||
wasPreviouslyBlocked | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
recentEditCount | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
recentContentEditCount | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 | |||
onUserGetRights | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
onRevisionFromEditComplete | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onPageSaveComplete | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
56 | |||
onAutopromoteCondition | |
0.00% |
0 / 102 |
|
0.00% |
0 / 1 |
992 | |||
onUserLoadAfterLoadFromSession | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
onWikiExporter__dumpStableQuery | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
gnsmQueryModifier | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
onEchoGetDefaultNotifiedUsers | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
onUserMergeAccountFields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onMergeAccountFromTo | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
onDeleteAccount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onBeforeRevertedTagUpdate | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName |
3 | // phpcs:disable MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic |
4 | |
5 | use MediaWiki\Context\RequestContext; |
6 | use MediaWiki\Deferred\DeferredUpdates; |
7 | use MediaWiki\Extension\GoogleNewsSitemap\Specials\GoogleNewsSitemap; |
8 | use MediaWiki\Hook\ArticleMergeCompleteHook; |
9 | use MediaWiki\Hook\ArticleRevisionVisibilitySetHook; |
10 | use MediaWiki\Hook\MagicWordwgVariableIDsHook; |
11 | use MediaWiki\Hook\MediaWikiServicesHook; |
12 | use MediaWiki\Hook\PageMoveCompleteHook; |
13 | use MediaWiki\Hook\ParserFirstCallInitHook; |
14 | use MediaWiki\Hook\ParserGetVariableValueSwitchHook; |
15 | use MediaWiki\Hook\RecentChange_saveHook; |
16 | use MediaWiki\Hook\WikiExporter__dumpStableQueryHook; |
17 | use MediaWiki\MediaWikiServices; |
18 | use MediaWiki\Page\Hook\ArticleDeleteCompleteHook; |
19 | use MediaWiki\Page\Hook\ArticleUndeleteHook; |
20 | use MediaWiki\Page\Hook\RevisionFromEditCompleteHook; |
21 | use MediaWiki\Page\Hook\RevisionUndeletedHook; |
22 | use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook; |
23 | use MediaWiki\Permissions\Hook\UserGetRightsHook; |
24 | use MediaWiki\Revision\RevisionRecord; |
25 | use MediaWiki\Storage\EditResult; |
26 | use MediaWiki\Storage\Hook\BeforeRevertedTagUpdateHook; |
27 | use MediaWiki\Storage\Hook\PageSaveCompleteHook; |
28 | use MediaWiki\Storage\Hook\RevisionDataUpdatesHook; |
29 | use MediaWiki\Title\Title; |
30 | use MediaWiki\User\ActorMigration; |
31 | use MediaWiki\User\Hook\AutopromoteConditionHook; |
32 | use MediaWiki\User\Hook\UserLoadAfterLoadFromSessionHook; |
33 | use MediaWiki\User\User; |
34 | use MediaWiki\User\UserIdentity; |
35 | use Wikimedia\Rdbms\Database; |
36 | use Wikimedia\Rdbms\IReadableDatabase; |
37 | use Wikimedia\Rdbms\SelectQueryBuilder; |
38 | |
39 | /** |
40 | * Class containing hooked functions for a FlaggedRevs environment |
41 | */ |
42 | class 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 | } |