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