Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
53.87% |
153 / 284 |
|
18.18% |
2 / 11 |
CRAP | |
0.00% |
0 / 1 |
SpecialNuke | |
53.87% |
153 / 284 |
|
18.18% |
2 / 11 |
317.38 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
65.62% |
21 / 32 |
|
0.00% |
0 / 1 |
17.85 | |||
promptForm | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
2 | |||
listForm | |
92.47% |
86 / 93 |
|
0.00% |
0 / 1 |
11.05 | |||
getNewPages | |
90.62% |
29 / 32 |
|
0.00% |
0 / 1 |
8.05 | |||
doDelete | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
110 | |||
prefixSearchSubpages | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDeleteReason | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
4.77 | |||
getNukeHookRunner | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Nuke; |
4 | |
5 | use DeletePageJob; |
6 | use HTMLForm; |
7 | use JobQueueGroup; |
8 | use MediaWiki\CommentStore\CommentStore; |
9 | use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner; |
10 | use MediaWiki\Html\Html; |
11 | use MediaWiki\Html\ListToggle; |
12 | use MediaWiki\Page\File\FileDeleteForm; |
13 | use MediaWiki\Permissions\PermissionManager; |
14 | use MediaWiki\Request\WebRequest; |
15 | use MediaWiki\SpecialPage\SpecialPage; |
16 | use MediaWiki\Title\Title; |
17 | use MediaWiki\User\UserFactory; |
18 | use MediaWiki\User\UserNamePrefixSearch; |
19 | use MediaWiki\User\UserNameUtils; |
20 | use OOUI\DropdownInputWidget; |
21 | use OOUI\FieldLayout; |
22 | use OOUI\TextInputWidget; |
23 | use PermissionsError; |
24 | use RepoGroup; |
25 | use UserBlockedError; |
26 | use Wikimedia\Rdbms\IConnectionProvider; |
27 | use Wikimedia\Rdbms\SelectQueryBuilder; |
28 | use Xml; |
29 | |
30 | class SpecialNuke extends SpecialPage { |
31 | |
32 | /** @var NukeHookRunner|null */ |
33 | private $hookRunner; |
34 | |
35 | private JobQueueGroup $jobQueueGroup; |
36 | private IConnectionProvider $dbProvider; |
37 | private PermissionManager $permissionManager; |
38 | private RepoGroup $repoGroup; |
39 | private UserFactory $userFactory; |
40 | private UserNamePrefixSearch $userNamePrefixSearch; |
41 | private UserNameUtils $userNameUtils; |
42 | |
43 | public function __construct( |
44 | JobQueueGroup $jobQueueGroup, |
45 | IConnectionProvider $dbProvider, |
46 | PermissionManager $permissionManager, |
47 | RepoGroup $repoGroup, |
48 | UserFactory $userFactory, |
49 | UserNamePrefixSearch $userNamePrefixSearch, |
50 | UserNameUtils $userNameUtils |
51 | ) { |
52 | parent::__construct( 'Nuke', 'nuke' ); |
53 | $this->jobQueueGroup = $jobQueueGroup; |
54 | $this->dbProvider = $dbProvider; |
55 | $this->permissionManager = $permissionManager; |
56 | $this->repoGroup = $repoGroup; |
57 | $this->userFactory = $userFactory; |
58 | $this->userNamePrefixSearch = $userNamePrefixSearch; |
59 | $this->userNameUtils = $userNameUtils; |
60 | } |
61 | |
62 | public function doesWrites() { |
63 | return true; |
64 | } |
65 | |
66 | /** |
67 | * @param null|string $par |
68 | */ |
69 | public function execute( $par ) { |
70 | $this->setHeaders(); |
71 | $this->checkPermissions(); |
72 | $this->checkReadOnly(); |
73 | $this->outputHeader(); |
74 | $this->addHelpLink( 'Help:Extension:Nuke' ); |
75 | |
76 | $currentUser = $this->getUser(); |
77 | $block = $currentUser->getBlock(); |
78 | |
79 | // appliesToRight is presently a no-op, since there is no handling for `delete`, |
80 | // and so will return `null`. `true` will be returned if the block actively |
81 | // applies to `delete`, and both `null` and `true` should result in an error |
82 | if ( $block && ( $block->isSitewide() || |
83 | ( $block->appliesToRight( 'delete' ) !== false ) ) |
84 | ) { |
85 | throw new UserBlockedError( $block ); |
86 | } |
87 | |
88 | $req = $this->getRequest(); |
89 | $target = trim( $req->getText( 'target', $par ?? '' ) ); |
90 | |
91 | // Normalise name |
92 | if ( $target !== '' ) { |
93 | $user = $this->userFactory->newFromName( $target ); |
94 | if ( $user ) { |
95 | $target = $user->getName(); |
96 | } |
97 | } |
98 | |
99 | $reason = $this->getDeleteReason( $this->getRequest(), $target ); |
100 | |
101 | $limit = $req->getInt( 'limit', 500 ); |
102 | $namespace = $req->getIntOrNull( 'namespace' ); |
103 | |
104 | if ( $req->wasPosted() |
105 | && $currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) ) |
106 | ) { |
107 | if ( $req->getRawVal( 'action' ) === 'delete' ) { |
108 | $pages = $req->getArray( 'pages' ); |
109 | |
110 | if ( $pages ) { |
111 | $this->doDelete( $pages, $reason ); |
112 | return; |
113 | } |
114 | } elseif ( $req->getRawVal( 'action' ) === 'submit' ) { |
115 | $this->listForm( $target, $reason, $limit, $namespace ); |
116 | } else { |
117 | $this->promptForm(); |
118 | } |
119 | } elseif ( $target === '' ) { |
120 | $this->promptForm(); |
121 | } else { |
122 | $this->listForm( $target, $reason, $limit, $namespace ); |
123 | } |
124 | } |
125 | |
126 | /** |
127 | * Prompt for a username or IP address. |
128 | * |
129 | * @param string $userName |
130 | */ |
131 | protected function promptForm( $userName = '' ): void { |
132 | $out = $this->getOutput(); |
133 | |
134 | $out->addWikiMsg( 'nuke-tools' ); |
135 | |
136 | $formDescriptor = [ |
137 | 'nuke-target' => [ |
138 | 'id' => 'nuke-target', |
139 | 'default' => $userName, |
140 | 'label' => $this->msg( 'nuke-userorip' )->text(), |
141 | 'type' => 'user', |
142 | 'name' => 'target', |
143 | 'autofocus' => true |
144 | ], |
145 | 'nuke-pattern' => [ |
146 | 'id' => 'nuke-pattern', |
147 | 'label' => $this->msg( 'nuke-pattern' )->text(), |
148 | 'maxLength' => 40, |
149 | 'type' => 'text', |
150 | 'name' => 'pattern' |
151 | ], |
152 | 'namespace' => [ |
153 | 'id' => 'nuke-namespace', |
154 | 'type' => 'namespaceselect', |
155 | 'label' => $this->msg( 'nuke-namespace' )->text(), |
156 | 'all' => 'all', |
157 | 'name' => 'namespace' |
158 | ], |
159 | 'limit' => [ |
160 | 'id' => 'nuke-limit', |
161 | 'maxLength' => 7, |
162 | 'default' => 500, |
163 | 'label' => $this->msg( 'nuke-maxpages' )->text(), |
164 | 'type' => 'int', |
165 | 'name' => 'limit' |
166 | ] |
167 | ]; |
168 | |
169 | HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) |
170 | ->setName( 'massdelete' ) |
171 | ->setFormIdentifier( 'massdelete' ) |
172 | ->setWrapperLegendMsg( 'nuke' ) |
173 | ->setSubmitTextMsg( 'nuke-submit-user' ) |
174 | ->setSubmitName( 'nuke-submit-user' ) |
175 | ->setAction( $this->getPageTitle()->getLocalURL( 'action=submit' ) ) |
176 | ->prepareForm() |
177 | ->displayForm( false ); |
178 | } |
179 | |
180 | /** |
181 | * Display list of pages to delete. |
182 | * |
183 | * @param string $username |
184 | * @param string $reason |
185 | * @param int $limit |
186 | * @param int|null $namespace |
187 | */ |
188 | protected function listForm( $username, $reason, $limit, $namespace = null ): void { |
189 | $out = $this->getOutput(); |
190 | |
191 | $pages = $this->getNewPages( $username, $limit, $namespace ); |
192 | |
193 | if ( !$pages ) { |
194 | if ( $username === '' ) { |
195 | $out->addWikiMsg( 'nuke-nopages-global' ); |
196 | } else { |
197 | $out->addWikiMsg( 'nuke-nopages', $username ); |
198 | } |
199 | |
200 | $this->promptForm( $username ); |
201 | return; |
202 | } |
203 | |
204 | $out->addModules( 'ext.nuke.confirm' ); |
205 | |
206 | if ( $username === '' ) { |
207 | $out->addWikiMsg( 'nuke-list-multiple' ); |
208 | } else { |
209 | $out->addWikiMsg( 'nuke-list', $username ); |
210 | } |
211 | |
212 | $nuke = $this->getPageTitle(); |
213 | |
214 | $options = Xml::listDropdownOptions( |
215 | $this->msg( 'deletereason-dropdown' )->inContentLanguage()->text(), |
216 | [ 'other' => $this->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ] |
217 | ); |
218 | |
219 | $dropdown = new FieldLayout( |
220 | new DropdownInputWidget( [ |
221 | 'name' => 'wpDeleteReasonList', |
222 | 'inputId' => 'wpDeleteReasonList', |
223 | 'tabIndex' => 1, |
224 | 'infusable' => true, |
225 | 'value' => '', |
226 | 'options' => Xml::listDropdownOptionsOoui( $options ), |
227 | ] ), |
228 | [ |
229 | 'label' => $this->msg( 'deletecomment' )->text(), |
230 | 'align' => 'top', |
231 | ] |
232 | ); |
233 | $reasonField = new FieldLayout( |
234 | new TextInputWidget( [ |
235 | 'name' => 'wpReason', |
236 | 'inputId' => 'wpReason', |
237 | 'tabIndex' => 2, |
238 | 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT, |
239 | 'infusable' => true, |
240 | 'value' => $reason, |
241 | 'autofocus' => true, |
242 | ] ), |
243 | [ |
244 | 'label' => $this->msg( 'deleteotherreason' )->text(), |
245 | 'align' => 'top', |
246 | ] |
247 | ); |
248 | |
249 | $out->enableOOUI(); |
250 | $out->addHTML( |
251 | Html::openElement( 'form', [ |
252 | 'action' => $nuke->getLocalURL( 'action=delete' ), |
253 | 'method' => 'post', |
254 | 'name' => 'nukelist' ] |
255 | ) . |
256 | Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . |
257 | $dropdown . |
258 | $reasonField . |
259 | // Select: All, None, Invert |
260 | ( new ListToggle( $this->getOutput() ) )->getHTML() . |
261 | '<ul>' |
262 | ); |
263 | |
264 | $wordSeparator = $this->msg( 'word-separator' )->escaped(); |
265 | $commaSeparator = $this->msg( 'comma-separator' )->escaped(); |
266 | |
267 | $linkRenderer = $this->getLinkRenderer(); |
268 | $localRepo = $this->repoGroup->getLocalRepo(); |
269 | foreach ( $pages as [ $title, $userName ] ) { |
270 | /** |
271 | * @var $title Title |
272 | */ |
273 | |
274 | $image = $title->inNamespace( NS_FILE ) ? $localRepo->newFile( $title ) : false; |
275 | $thumb = $image && $image->exists() ? |
276 | $image->transform( [ 'width' => 120, 'height' => 120 ], 0 ) : |
277 | false; |
278 | |
279 | $userNameText = $userName ? |
280 | $this->msg( 'nuke-editby', $userName )->parse() . $commaSeparator : |
281 | ''; |
282 | $changesLink = $linkRenderer->makeKnownLink( |
283 | $title, |
284 | $this->msg( 'nuke-viewchanges' )->text(), |
285 | [], |
286 | [ 'action' => 'history' ] |
287 | ); |
288 | $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : []; |
289 | $out->addHTML( '<li>' . |
290 | Html::check( |
291 | 'pages[]', |
292 | true, |
293 | [ 'value' => $title->getPrefixedDBkey() ] |
294 | ) . "\u{00A0}" . |
295 | ( $thumb ? $thumb->toHtml( [ 'desc-link' => true ] ) : '' ) . |
296 | $linkRenderer->makeKnownLink( $title, null, [], $query ) . $wordSeparator . |
297 | $this->msg( 'parentheses' )->rawParams( $userNameText . $changesLink )->escaped() . |
298 | "</li>\n" ); |
299 | } |
300 | |
301 | $out->addHTML( |
302 | "</ul>\n" . |
303 | Html::submitButton( $this->msg( 'nuke-submit-delete' )->text() ) . |
304 | '</form>' |
305 | ); |
306 | } |
307 | |
308 | /** |
309 | * Gets a list of new pages by the specified user or everyone when none is specified. |
310 | * |
311 | * @param string $username |
312 | * @param int $limit |
313 | * @param int|null $namespace |
314 | * |
315 | * @return array{0:Title,1:string|false}[] |
316 | */ |
317 | protected function getNewPages( $username, $limit, $namespace = null ): array { |
318 | $dbr = $this->dbProvider->getReplicaDatabase(); |
319 | $queryBuilder = $dbr->newSelectQueryBuilder() |
320 | ->select( [ 'rc_namespace', 'rc_title' ] ) |
321 | ->from( 'recentchanges' ) |
322 | ->join( 'actor', null, 'actor_id=rc_actor' ) |
323 | ->where( |
324 | $dbr->expr( 'rc_new', '=', 1 )->orExpr( |
325 | $dbr->expr( 'rc_log_type', '=', 'upload' ) |
326 | ->and( 'rc_log_action', '=', 'upload' ) |
327 | ) |
328 | ) |
329 | ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC ) |
330 | ->limit( $limit ); |
331 | |
332 | if ( $username === '' ) { |
333 | $queryBuilder->field( 'actor_name', 'rc_user_text' ); |
334 | } else { |
335 | $queryBuilder->andWhere( [ 'actor_name' => $username ] ); |
336 | } |
337 | |
338 | if ( $namespace !== null ) { |
339 | $queryBuilder->andWhere( [ 'rc_namespace' => $namespace ] ); |
340 | } |
341 | |
342 | $pattern = $this->getRequest()->getText( 'pattern' ); |
343 | if ( $pattern !== null && trim( $pattern ) !== '' ) { |
344 | // $pattern is a SQL pattern supporting wildcards, so buildLike() will not work. |
345 | // Wildcards are escaped using '\', so LikeValue/LikeMatch will not work either. |
346 | $queryBuilder->andWhere( 'rc_title LIKE ' . $dbr->addQuotes( $pattern ) ); |
347 | } |
348 | |
349 | $result = $queryBuilder->caller( __METHOD__ )->fetchResultSet(); |
350 | /** @var array{0:Title,1:string|false}[] $pages */ |
351 | $pages = []; |
352 | foreach ( $result as $row ) { |
353 | $pages[] = [ |
354 | Title::makeTitle( $row->rc_namespace, $row->rc_title ), |
355 | $username === '' ? $row->rc_user_text : false |
356 | ]; |
357 | } |
358 | |
359 | // Allows other extensions to provide pages to be nuked that don't use |
360 | // the recentchanges table the way mediawiki-core does |
361 | $this->getNukeHookRunner()->onNukeGetNewPages( $username, $pattern, $namespace, $limit, $pages ); |
362 | |
363 | // Re-enforcing the limit *after* the hook because other extensions |
364 | // may add and/or remove pages. We need to make sure we don't end up |
365 | // with more pages than $limit. |
366 | if ( count( $pages ) > $limit ) { |
367 | $pages = array_slice( $pages, 0, $limit ); |
368 | } |
369 | |
370 | return $pages; |
371 | } |
372 | |
373 | /** |
374 | * Does the actual deletion of the pages. |
375 | * |
376 | * @param array $pages The pages to delete |
377 | * @param string $reason |
378 | * @throws PermissionsError |
379 | */ |
380 | protected function doDelete( array $pages, $reason ): void { |
381 | $res = []; |
382 | $jobs = []; |
383 | $user = $this->getUser(); |
384 | |
385 | $localRepo = $this->repoGroup->getLocalRepo(); |
386 | foreach ( $pages as $page ) { |
387 | $title = Title::newFromText( $page ); |
388 | |
389 | $deletionResult = false; |
390 | if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) { |
391 | $res[] = $this->msg( |
392 | $deletionResult ? 'nuke-deleted' : 'nuke-not-deleted', |
393 | wfEscapeWikiText( $title->getPrefixedText() ) |
394 | )->parse(); |
395 | continue; |
396 | } |
397 | |
398 | $permission_errors = $this->permissionManager->getPermissionErrors( 'delete', $user, $title ); |
399 | |
400 | if ( $permission_errors !== [] ) { |
401 | throw new PermissionsError( 'delete', $permission_errors ); |
402 | } |
403 | |
404 | $file = $title->getNamespace() === NS_FILE ? $localRepo->newFile( $title ) : false; |
405 | if ( $file ) { |
406 | // Must be passed by reference |
407 | $oldimage = null; |
408 | $status = FileDeleteForm::doDelete( |
409 | $title, |
410 | $file, |
411 | $oldimage, |
412 | $reason, |
413 | false, |
414 | $user |
415 | ); |
416 | } else { |
417 | $job = new DeletePageJob( [ |
418 | 'namespace' => $title->getNamespace(), |
419 | 'title' => $title->getDBKey(), |
420 | 'reason' => $reason, |
421 | 'userId' => $user->getId(), |
422 | 'wikiPageId' => $title->getId(), |
423 | 'suppress' => false, |
424 | 'tags' => '[]', |
425 | 'logsubtype' => 'delete', |
426 | ] ); |
427 | $jobs[] = $job; |
428 | $status = 'job'; |
429 | } |
430 | |
431 | if ( $status === 'job' ) { |
432 | $res[] = $this->msg( |
433 | 'nuke-deletion-queued', |
434 | wfEscapeWikiText( $title->getPrefixedText() ) |
435 | )->parse(); |
436 | } else { |
437 | $res[] = $this->msg( |
438 | $status->isOK() ? 'nuke-deleted' : 'nuke-not-deleted', |
439 | wfEscapeWikiText( $title->getPrefixedText() ) |
440 | )->parse(); |
441 | } |
442 | } |
443 | |
444 | if ( $jobs ) { |
445 | $this->jobQueueGroup->push( $jobs ); |
446 | } |
447 | |
448 | $this->getOutput()->addHTML( |
449 | "<ul>\n<li>" . |
450 | implode( "</li>\n<li>", $res ) . |
451 | "</li>\n</ul>\n" |
452 | ); |
453 | $this->getOutput()->addWikiMsg( 'nuke-delete-more' ); |
454 | } |
455 | |
456 | /** |
457 | * Return an array of subpages beginning with $search that this special page will accept. |
458 | * |
459 | * @param string $search Prefix to search for |
460 | * @param int $limit Maximum number of results to return (usually 10) |
461 | * @param int $offset Number of results to skip (usually 0) |
462 | * @return string[] Matching subpages |
463 | */ |
464 | public function prefixSearchSubpages( $search, $limit, $offset ) { |
465 | $search = $this->userNameUtils->getCanonical( $search ); |
466 | if ( !$search ) { |
467 | // No prefix suggestion for invalid user |
468 | return []; |
469 | } |
470 | |
471 | // Autocomplete subpage as user list - public to allow caching |
472 | return $this->userNamePrefixSearch |
473 | ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset ); |
474 | } |
475 | |
476 | /** |
477 | * Group Special:Nuke with pagetools |
478 | * |
479 | * @return string |
480 | */ |
481 | protected function getGroupName() { |
482 | return 'pagetools'; |
483 | } |
484 | |
485 | private function getDeleteReason( WebRequest $request, string $target ): string { |
486 | $defaultReason = $target === '' |
487 | ? $this->msg( 'nuke-multiplepeople' )->inContentLanguage()->text() |
488 | : $this->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text(); |
489 | |
490 | $dropdownSelection = $request->getText( 'wpDeleteReasonList', 'other' ); |
491 | $reasonInput = $request->getText( 'wpReason', $defaultReason ); |
492 | |
493 | if ( $dropdownSelection === 'other' ) { |
494 | return $reasonInput; |
495 | } elseif ( $reasonInput !== '' ) { |
496 | // Entry from drop down menu + additional comment |
497 | $separator = $this->msg( 'colon-separator' )->inContentLanguage()->text(); |
498 | return $dropdownSelection . $separator . $reasonInput; |
499 | } else { |
500 | return $dropdownSelection; |
501 | } |
502 | } |
503 | |
504 | private function getNukeHookRunner(): NukeHookRunner { |
505 | $this->hookRunner ??= new NukeHookRunner( $this->getHookContainer() ); |
506 | return $this->hookRunner; |
507 | } |
508 | } |