Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.87% covered (warning)
53.87%
153 / 284
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialNuke
53.87% covered (warning)
53.87%
153 / 284
18.18% covered (danger)
18.18%
2 / 11
317.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
65.62% covered (warning)
65.62%
21 / 32
0.00% covered (danger)
0.00%
0 / 1
17.85
 promptForm
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
2
 listForm
92.47% covered (success)
92.47%
86 / 93
0.00% covered (danger)
0.00%
0 / 1
11.05
 getNewPages
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
8.05
 doDelete
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
110
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeleteReason
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 getNukeHookRunner
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\Nuke;
4
5use DeletePageJob;
6use HTMLForm;
7use JobQueueGroup;
8use MediaWiki\CommentStore\CommentStore;
9use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner;
10use MediaWiki\Html\Html;
11use MediaWiki\Html\ListToggle;
12use MediaWiki\Page\File\FileDeleteForm;
13use MediaWiki\Permissions\PermissionManager;
14use MediaWiki\Request\WebRequest;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Title\Title;
17use MediaWiki\User\UserFactory;
18use MediaWiki\User\UserNamePrefixSearch;
19use MediaWiki\User\UserNameUtils;
20use OOUI\DropdownInputWidget;
21use OOUI\FieldLayout;
22use OOUI\TextInputWidget;
23use PermissionsError;
24use RepoGroup;
25use UserBlockedError;
26use Wikimedia\Rdbms\IConnectionProvider;
27use Wikimedia\Rdbms\SelectQueryBuilder;
28use Xml;
29
30class 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}