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