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