Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
69.30% |
79 / 114 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
RevisionCheck | |
69.30% |
79 / 114 |
|
55.56% |
5 / 9 |
86.30 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getUndoContent | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
doRollback | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
revertPreCheck | |
95.92% |
47 / 49 |
|
0.00% |
0 / 1 |
18 | |||
maybeRollback | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
9 | |||
shouldSkipUser | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
areUsersEqual | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isProtectedPage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
isNewPageCreation | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
16 | * |
17 | * @file |
18 | */ |
19 | |
20 | namespace AutoModerator; |
21 | |
22 | use AutoModerator\Services\AutoModeratorRollback; |
23 | use MediaWiki\Config\Config; |
24 | use MediaWiki\Content\Content; |
25 | use MediaWiki\Content\ContentHandler; |
26 | use MediaWiki\Page\WikiPageFactory; |
27 | use MediaWiki\Permissions\PermissionManager; |
28 | use MediaWiki\Permissions\RestrictionStore; |
29 | use MediaWiki\Revision\RevisionRecord; |
30 | use MediaWiki\Revision\RevisionStore; |
31 | use MediaWiki\Revision\SlotRecord; |
32 | use MediaWiki\User\ExternalUserNames; |
33 | use MediaWiki\User\User; |
34 | use MediaWiki\User\UserIdentity; |
35 | use Psr\Log\LoggerInterface; |
36 | use StatusValue; |
37 | use WikiPage; |
38 | |
39 | class RevisionCheck { |
40 | |
41 | /** @var int */ |
42 | private int $wikiPageId; |
43 | |
44 | /** @var WikiPageFactory */ |
45 | private WikiPageFactory $wikiPageFactory; |
46 | |
47 | /** @var int */ |
48 | private int $revId; |
49 | |
50 | /** @var User */ |
51 | private User $autoModeratorUser; |
52 | |
53 | /** @var RevisionStore */ |
54 | private RevisionStore $revisionStore; |
55 | |
56 | /** @var Config */ |
57 | private Config $wikiConfig; |
58 | |
59 | /** @var Config */ |
60 | private Config $config; |
61 | |
62 | /** @var string */ |
63 | public string $undoSummary; |
64 | |
65 | /** @var ContentHandler */ |
66 | private ContentHandler $contentHandler; |
67 | |
68 | /** @var bool */ |
69 | private bool $enforce; |
70 | |
71 | /** @var AutoModeratorRollback */ |
72 | private AutoModeratorRollback $rollbackPage; |
73 | |
74 | /** |
75 | * @param int $wikiPageId WikiPage ID of |
76 | * @param WikiPageFactory $wikiPageFactory |
77 | * @param int $revId New revision ID |
78 | * @param User $autoModeratorUser reverting user |
79 | * @param RevisionStore $revisionStore |
80 | * @param Config $wikiConfig |
81 | * @param Config $config |
82 | * @param ContentHandler $contentHandler |
83 | * @param string $undoSummary |
84 | * @param AutoModeratorRollback $rollbackPage |
85 | * @param bool $enforce Perform reverts if true, take no action if false |
86 | */ |
87 | public function __construct( |
88 | int $wikiPageId, |
89 | WikiPageFactory $wikiPageFactory, |
90 | int $revId, |
91 | User $autoModeratorUser, |
92 | RevisionStore $revisionStore, |
93 | Config $wikiConfig, |
94 | Config $config, |
95 | ContentHandler $contentHandler, |
96 | string $undoSummary, |
97 | AutoModeratorRollback $rollbackPage, |
98 | bool $enforce = false |
99 | ) { |
100 | $this->wikiPageId = $wikiPageId; |
101 | $this->wikiPageFactory = $wikiPageFactory; |
102 | $this->revId = $revId; |
103 | $this->autoModeratorUser = $autoModeratorUser; |
104 | $this->revisionStore = $revisionStore; |
105 | $this->wikiConfig = $wikiConfig; |
106 | $this->config = $config; |
107 | $this->contentHandler = $contentHandler; |
108 | $this->enforce = $enforce; |
109 | $this->undoSummary = $undoSummary; |
110 | $this->rollbackPage = $rollbackPage; |
111 | } |
112 | |
113 | /** |
114 | * Cribbed from EditPage.php |
115 | * Returns the result of a three-way merge when undoing changes. |
116 | * |
117 | * @param RevisionRecord $oldRev Revision that is being restored. Corresponds to |
118 | * `undoafter` URL parameter. |
119 | * @param ?string &$error If false is returned, this will be set to "norev" |
120 | * if the revision failed to load, or "failure" if the content handler |
121 | * failed to merge the required changes. |
122 | * @param WikiPage $wikiPage |
123 | * @param RevisionRecord $rev |
124 | * |
125 | * @return false|Content |
126 | */ |
127 | private function getUndoContent( |
128 | RevisionRecord $oldRev, |
129 | ?string &$error, |
130 | WikiPage $wikiPage, |
131 | RevisionRecord $rev |
132 | ) { |
133 | $currentContent = $wikiPage->getRevisionRecord() |
134 | ->getContent( SlotRecord::MAIN ); |
135 | $undoContent = $rev->getContent( SlotRecord::MAIN ); |
136 | $undoAfterContent = $oldRev->getContent( SlotRecord::MAIN ); |
137 | $undoIsLatest = $wikiPage->getRevisionRecord()->getId() === $this->revId; |
138 | if ( $currentContent === null |
139 | || $undoContent === null |
140 | || $undoAfterContent === null |
141 | ) { |
142 | $error = 'norev'; |
143 | return false; |
144 | } |
145 | |
146 | $content = $this->contentHandler->getUndoContent( |
147 | $currentContent, |
148 | $undoContent, |
149 | $undoAfterContent, |
150 | $undoIsLatest, |
151 | ); |
152 | if ( $content === false ) { |
153 | $error = 'failure'; |
154 | } |
155 | return $content; |
156 | } |
157 | |
158 | /** |
159 | * Perform rollback |
160 | */ |
161 | private function doRollback(): StatusValue { |
162 | return $this->rollbackPage |
163 | ->setSummary( $this->undoSummary ) |
164 | ->rollback(); |
165 | } |
166 | |
167 | /** |
168 | * Precheck a revision; if any of the checks don't pass, |
169 | * a revision won't be scored |
170 | * @param UserIdentity $user |
171 | * @param User $autoModeratorUser |
172 | * @param LoggerInterface $logger |
173 | * @param RevisionStore $revisionStore |
174 | * @param string[] $tags |
175 | * @param RestrictionStore $restrictionStore |
176 | * @param WikiPageFactory $wikiPageFactory |
177 | * @param Config $wikiConfig |
178 | * @param int $revId |
179 | * @param int $wikiPageId |
180 | * @return bool |
181 | */ |
182 | public static function revertPreCheck( UserIdentity $user, User $autoModeratorUser, LoggerInterface $logger, |
183 | RevisionStore $revisionStore, array $tags, RestrictionStore $restrictionStore, WikiPageFactory $wikiPageFactory, |
184 | Config $wikiConfig, int $revId, int $wikiPageId, PermissionManager $permissionManager ): bool { |
185 | // Skips reverts if AutoModerator is blocked |
186 | $autoModeratorBlock = $autoModeratorUser->getBlock(); |
187 | if ( $autoModeratorBlock && $autoModeratorBlock->appliesToPage( $wikiPageId ) ) { |
188 | $logger->debug( "AutoModerator skip rev" . __METHOD__ . " - AutoModerator is blocked" ); |
189 | return false; |
190 | } |
191 | // Skip AutoModerator edits |
192 | if ( self::areUsersEqual( $user, $autoModeratorUser ) ) { |
193 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - AutoMod edits' ); |
194 | return false; |
195 | } |
196 | $parentId = $revisionStore->getRevisionById( $revId )->getParentId(); |
197 | // Skip new page creations |
198 | if ( self::isNewPageCreation( $parentId ) ) { |
199 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - new page creation' ); |
200 | return false; |
201 | } |
202 | // Skip reverts made to an AutoModerator bot revert or if |
203 | // the user reverts their own edit |
204 | $revertTags = [ 'mw-manual-revert', 'mw-rollback', 'mw-undo', 'mw-reverted' ]; |
205 | $parentRev = $revisionStore->getRevisionById( $parentId ); |
206 | foreach ( $revertTags as $revertTag ) { |
207 | if ( in_array( $revertTag, $tags ) ) { |
208 | if ( !$parentRev ) { |
209 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - parent revision not found' ); |
210 | return false; |
211 | } |
212 | $parentRevUser = $parentRev->getUser(); |
213 | if ( $parentRevUser === null ) { |
214 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - parent revision user is null' ); |
215 | return false; |
216 | } |
217 | if ( self::areUsersEqual( $parentRevUser, $autoModeratorUser ) ) { |
218 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - AutoModerator reverts' ); |
219 | return false; |
220 | } |
221 | if ( self::areUsersEqual( $parentRevUser, $user ) ) { |
222 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - own reverts' ); |
223 | return false; |
224 | } |
225 | } |
226 | } |
227 | // Skip page moves |
228 | $moveTags = [ 'mw-new-redirect', 'mw-removed-redirect', 'mw-changed-redirect-target' ]; |
229 | foreach ( $moveTags as $moveTag ) { |
230 | if ( in_array( $moveTag, $tags ) ) { |
231 | return false; |
232 | } |
233 | } |
234 | // Skip edits from editors that have certain user rights |
235 | if ( self::shouldSkipUser( $permissionManager, $user, $wikiConfig ) ) { |
236 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - trusted user rights edits' ); |
237 | return false; |
238 | } |
239 | // Skip external users |
240 | if ( ExternalUserNames::isExternal( $user->getName() ) ) { |
241 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - external user' ); |
242 | return false; |
243 | } |
244 | $wikiPage = $wikiPageFactory->newFromID( $wikiPageId ); |
245 | // Skip null pages |
246 | if ( $wikiPage === null ) { |
247 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - wikiPage is null' ); |
248 | return false; |
249 | } |
250 | // Skip non-mainspace edit |
251 | if ( $wikiPage->getNamespace() !== NS_MAIN ) { |
252 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - non-mainspace edits' ); |
253 | return false; |
254 | } |
255 | // Skip protected pages that only admins can edit. |
256 | // Automoderator should be able to revert semi-protected pages, |
257 | // so we won't be skipping those on pre-check. |
258 | if ( self::isProtectedPage( $restrictionStore, $wikiPage ) ) { |
259 | $logger->debug( __METHOD__ . ': AutoModerator skip rev - protected page' ); |
260 | return false; |
261 | } |
262 | return true; |
263 | } |
264 | |
265 | /** |
266 | * Check revision; revert if it meets configured critera |
267 | * @param array $score |
268 | * @param string $revertRiskModelName |
269 | * @return array |
270 | */ |
271 | public function maybeRollback( array $score, string $revertRiskModelName ): array { |
272 | $reverted = 0; |
273 | $status = 'Not reverted'; |
274 | $probability = $score[ 'output' ][ 'probabilities' ][ 'true' ]; |
275 | $wikiPage = $this->wikiPageFactory->newFromID( $this->wikiPageId ); |
276 | $rev = $this->revisionStore->getRevisionById( $this->revId ); |
277 | // Check if the threshold should be taken from the language-agnostic |
278 | // or the multilingual model based on what model was chosen in the job |
279 | if ( $probability > Util::getRevertThreshold( $this->wikiConfig, $this->config, $revertRiskModelName ) ) { |
280 | $prevRev = $this->revisionStore->getPreviousRevision( $rev ); |
281 | $content = $this->getUndoContent( $prevRev, $this->undoSummary, $wikiPage, $rev ); |
282 | if ( !$content ) { |
283 | return [ $reverted => $this->undoSummary ]; |
284 | } |
285 | if ( $this->enforce ) { |
286 | $pageRollbackStatus = $this->doRollback(); |
287 | if ( !$pageRollbackStatus->isOK() ) { |
288 | $errorMessages = $pageRollbackStatus->getMessages( 'error' ); |
289 | // checks to see if there was an edit conflict or already rolled error message |
290 | // which would indicate that someone else |
291 | // has edited or rolled the page since the job began |
292 | if ( $errorMessages && |
293 | ( $errorMessages[0]->getKey() === "alreadyrolled" |
294 | || $errorMessages[0]->getKey() === "edit-conflict" ) ) { |
295 | $status = 'success'; |
296 | return [ $reverted => $status ]; |
297 | } |
298 | return [ $reverted => $errorMessages ? |
299 | wfMessage( $errorMessages[0] )->inLanguage( "en" )->plain() |
300 | : "Failed to save revision" ]; |
301 | } |
302 | } |
303 | $reverted = 1; |
304 | $status = 'success'; |
305 | } |
306 | return [ $reverted => $status ]; |
307 | } |
308 | |
309 | /** |
310 | * @param PermissionManager $permissionManager |
311 | * @param UserIdentity $user |
312 | * @param Config $wikiConfig |
313 | * @return bool |
314 | */ |
315 | public static function shouldSkipUser( PermissionManager $permissionManager, |
316 | UserIdentity $user, Config $wikiConfig ): bool { |
317 | return $permissionManager->userHasAnyRight( |
318 | $user, ...(array)$wikiConfig->get( 'AutoModeratorSkipUserRights' ) |
319 | ); |
320 | } |
321 | |
322 | /** |
323 | * @param UserIdentity $user |
324 | * @param UserIdentity $autoModeratorUser |
325 | * @return bool |
326 | */ |
327 | public static function areUsersEqual( UserIdentity $user, UserIdentity $autoModeratorUser ): bool { |
328 | return $user->equals( $autoModeratorUser ); |
329 | } |
330 | |
331 | /** |
332 | * @param RestrictionStore $restrictionStore |
333 | * @param WikiPage $wikiPage |
334 | * @return bool |
335 | */ |
336 | public static function isProtectedPage( RestrictionStore $restrictionStore, WikiPage $wikiPage ): bool { |
337 | return $restrictionStore->isProtected( $wikiPage ) |
338 | && !$restrictionStore->isSemiProtected( $wikiPage ); |
339 | } |
340 | |
341 | /** |
342 | * @param int|null $parentId |
343 | * @return bool |
344 | */ |
345 | public static function isNewPageCreation( ?int $parentId ): bool { |
346 | return $parentId === null || $parentId === 0; |
347 | } |
348 | } |