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