Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.26% covered (success)
99.26%
267 / 269
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialNuke
99.26% covered (success)
99.26%
267 / 269
93.33% covered (success)
93.33%
14 / 15
69
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
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%
37 / 37
100.00% covered (success)
100.00%
1 / 1
14
 getTempAccounts
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 getNukeContextFromRequest
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
8
 getUIRenderer
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 loadNamespacesFromRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 assertUserCanAccessTemporaryAccounts
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 showPromptForm
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showListForm
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 showConfirmForm
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 showResultPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNewPages
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
1 / 1
14
 doDelete
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
8
 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
 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 DateTime;
6use DeletePageJob;
7use ErrorPageError;
8use JobQueueGroup;
9use MediaWiki\CheckUser\Services\CheckUserTemporaryAccountsByIPLookup;
10use MediaWiki\Extension\Nuke\Form\SpecialNukeHTMLFormUIRenderer;
11use MediaWiki\Extension\Nuke\Form\SpecialNukeUIRenderer;
12use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner;
13use MediaWiki\Language\Language;
14use MediaWiki\Page\File\FileDeleteForm;
15use MediaWiki\Permissions\PermissionManager;
16use MediaWiki\Request\WebRequest;
17use MediaWiki\SpecialPage\SpecialPage;
18use MediaWiki\Status\Status;
19use MediaWiki\Title\NamespaceInfo;
20use MediaWiki\Title\Title;
21use MediaWiki\User\Options\UserOptionsLookup;
22use MediaWiki\User\User;
23use MediaWiki\User\UserFactory;
24use MediaWiki\User\UserNamePrefixSearch;
25use MediaWiki\User\UserNameUtils;
26use PermissionsError;
27use RepoGroup;
28use UserBlockedError;
29use Wikimedia\IPUtils;
30use Wikimedia\Rdbms\IConnectionProvider;
31
32class SpecialNuke extends SpecialPage {
33
34    /** @var NukeHookRunner|null */
35    private $hookRunner;
36
37    private JobQueueGroup $jobQueueGroup;
38    private IConnectionProvider $dbProvider;
39    private PermissionManager $permissionManager;
40    private RepoGroup $repoGroup;
41    private UserFactory $userFactory;
42    private UserOptionsLookup $userOptionsLookup;
43    private UserNamePrefixSearch $userNamePrefixSearch;
44    private UserNameUtils $userNameUtils;
45    private NamespaceInfo $namespaceInfo;
46    private Language $contentLanguage;
47    /** @var CheckUserTemporaryAccountsByIPLookup|null */
48    private $checkUserTemporaryAccountsByIPLookup = null;
49
50    /**
51     * Action keyword for the "prompt" step.
52     */
53    public const ACTION_PROMPT = 'prompt';
54    /**
55     * Action keyword for the "list" step.
56     */
57    public const ACTION_LIST = 'list';
58    /**
59     * Action keyword for the "confirm" step.
60     */
61    public const ACTION_CONFIRM = 'confirm';
62    /**
63     * Action keyword for the "delete/results" step.
64     */
65    public const ACTION_DELETE = 'delete';
66
67    /**
68     * Separator for the hidden "page list" fields.
69     */
70    public const PAGE_LIST_SEPARATOR = '|';
71
72    /**
73     * Separator for the namespace list. This constant comes from the separator used by
74     * HTMLNamespacesMultiselectField.
75     */
76    public const NAMESPACE_LIST_SEPARATOR = "\n";
77
78    /**
79     * @inheritDoc
80     */
81    public function __construct(
82        JobQueueGroup $jobQueueGroup,
83        IConnectionProvider $dbProvider,
84        PermissionManager $permissionManager,
85        RepoGroup $repoGroup,
86        UserFactory $userFactory,
87        UserOptionsLookup $userOptionsLookup,
88        UserNamePrefixSearch $userNamePrefixSearch,
89        UserNameUtils $userNameUtils,
90        NamespaceInfo $namespaceInfo,
91        Language $contentLanguage,
92        $checkUserTemporaryAccountsByIPLookup = null
93    ) {
94        parent::__construct( 'Nuke', 'nuke' );
95        $this->jobQueueGroup = $jobQueueGroup;
96        $this->dbProvider = $dbProvider;
97        $this->permissionManager = $permissionManager;
98        $this->repoGroup = $repoGroup;
99        $this->userFactory = $userFactory;
100        $this->userOptionsLookup = $userOptionsLookup;
101        $this->userNamePrefixSearch = $userNamePrefixSearch;
102        $this->userNameUtils = $userNameUtils;
103        $this->namespaceInfo = $namespaceInfo;
104        $this->contentLanguage = $contentLanguage;
105        $this->checkUserTemporaryAccountsByIPLookup = $checkUserTemporaryAccountsByIPLookup;
106    }
107
108    /**
109     * @inheritDoc
110     * @codeCoverageIgnore
111     */
112    public function doesWrites() {
113        return true;
114    }
115
116    /**
117     * @param null|string $par
118     */
119    public function execute( $par ) {
120        $this->setHeaders();
121        $this->checkPermissions();
122        $this->checkReadOnly();
123        $this->outputHeader();
124        $this->addHelpLink( 'Help:Extension:Nuke' );
125
126        $currentUser = $this->getUser();
127        $block = $currentUser->getBlock();
128
129        // appliesToRight is presently a no-op, since there is no handling for `delete`,
130        // and so will return `null`. `true` will be returned if the block actively
131        // applies to `delete`, and both `null` and `true` should result in an error
132        if ( $block && ( $block->isSitewide() ||
133            ( $block->appliesToRight( 'delete' ) !== false ) )
134        ) {
135            throw new UserBlockedError( $block );
136        }
137
138        $req = $this->getRequest();
139        $nukeContext = $this->getNukeContextFromRequest( $par );
140
141        if ( $nukeContext->validatePrompt() !== true ) {
142            // Something is wrong with filters. Immediately return the prompt form again.
143            $this->showPromptForm( $nukeContext );
144            return;
145        }
146
147        switch ( $nukeContext->getAction() ) {
148            case self::ACTION_DELETE:
149            case self::ACTION_CONFIRM:
150                if ( !$req->wasPosted()
151                    || !$currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) )
152                ) {
153                    // If the form was not posted or the edit token didn't match, something
154                    // must have gone wrong. Show the prompt form again.
155                    $this->showPromptForm( $nukeContext );
156                    break;
157                }
158
159                if ( !$nukeContext->hasPages() ) {
160                    if ( !$nukeContext->hasOriginalPages() ) {
161                        // No pages were requested. This is an early confirm attempt without having
162                        // listed the pages at all. Show the list form again.
163                        $this->showPromptForm( $nukeContext );
164                    } else {
165                        // Pages were not requested but a page list exists. The user did not select any
166                        // pages. Show the list form again.
167                        $this->showListForm( $nukeContext );
168                    }
169                    break;
170                }
171
172                if ( $nukeContext->getAction() === self::ACTION_DELETE ) {
173                    $deletedPageStatuses = $this->doDelete( $nukeContext );
174                    $this->showResultPage( $nukeContext, $deletedPageStatuses );
175                } else {
176                    $this->showConfirmForm( $nukeContext );
177                }
178                break;
179            case self::ACTION_LIST:
180                $this->showListForm( $nukeContext );
181                break;
182            default:
183                $this->showPromptForm( $nukeContext );
184                break;
185        }
186    }
187
188    /**
189     * Return a list of temporary accounts that are known to have edited from the context's target.
190     * Calls to this method result in a log entry being generated for the logged-in user account
191     * making the request.
192     *
193     * @param NukeContext $context
194     * @return string[] A list of temporary account usernames associated with the IP address
195     */
196    protected function getTempAccounts( NukeContext $context ): array {
197        if ( !$this->checkUserTemporaryAccountsByIPLookup ) {
198            return [];
199        }
200        $status = $this->checkUserTemporaryAccountsByIPLookup->get(
201            $context->getTarget(),
202            $this->getAuthority(),
203            true
204        );
205        if ( $status->isGood() ) {
206            return $status->getValue();
207        }
208        return [];
209    }
210
211    /**
212     * Load the Nuke context from request data ({@link SpecialPage::getRequest}).
213     *
214     * @param string|null $par
215     * @return NukeContext
216     */
217    protected function getNukeContextFromRequest( ?string $par ): NukeContext {
218        $req = $this->getRequest();
219
220        $target = trim( $req->getText( 'target', $par ?? '' ) );
221
222        // Normalise name
223        if ( $target !== '' ) {
224            $user = $this->userFactory->newFromName( $target );
225            if ( $user ) {
226                $target = $user->getName();
227            }
228        }
229
230        $namespaces = $this->loadNamespacesFromRequest( $req );
231        // Set $namespaces to null if it's empty
232        if ( count( $namespaces ) == 0 ) {
233            $namespaces = null;
234        }
235
236        $action = $req->getRawVal( 'action' );
237        if ( !$action ) {
238            if ( $target !== '' ) {
239                // Target was supplied but action was not. Imply 'list' action.
240                $action = self::ACTION_LIST;
241            } else {
242                $action = self::ACTION_PROMPT;
243            }
244        }
245
246        // This uses a string value to avoid having to generate hundreds of hidden <input>s.
247        $originalPages = explode(
248            self::PAGE_LIST_SEPARATOR,
249            $req->getText( 'originalPageList' )
250        );
251        if ( count( $originalPages ) == 1 && $originalPages[0] == "" ) {
252            $originalPages = [];
253        }
254
255        return new NukeContext( [
256            'requestContext' => $this->getContext(),
257            'useTemporaryAccounts' => $this->checkUserTemporaryAccountsByIPLookup != null,
258
259            'action' => $action,
260            'target' => $target,
261            'listedTarget' => trim( $req->getText( 'listedTarget', $target ) ),
262            'pattern' => $req->getText( 'pattern' ),
263            'limit' => $req->getInt( 'limit', 500 ),
264            'namespaces' => $namespaces,
265
266            'dateFrom' => $req->getText( 'wpdateFrom' ),
267            'dateTo' => $req->getText( 'wpdateTo' ),
268
269            'pages' => $req->getArray( 'pages', [] ),
270            'originalPages' => $originalPages
271        ] );
272    }
273
274    /**
275     * Get the UI renderer for a given type.
276     *
277     * @param NukeContext $context
278     * @return SpecialNukeUIRenderer
279     */
280    protected function getUIRenderer(
281        NukeContext $context
282    ): SpecialNukeUIRenderer {
283        // Permit overriding the UI type with the `?nukeUI=` query parameter.
284        $formType = $this->getRequest()->getText( 'nukeUI' );
285        if ( !$formType ) {
286            $formType = $this->getConfig()->get( NukeConfigNames::UIType ) ?? 'htmlform';
287        }
288
289        // Possible values: 'codex', 'htmlform'
290        switch ( $formType ) {
291            // case 'codex': to be implemented (T153988)
292            case 'htmlform':
293            default:
294                return new SpecialNukeHTMLFormUIRenderer(
295                    $context,
296                    $this,
297                    $this->repoGroup,
298                    $this->getLinkRenderer(),
299                    $this->namespaceInfo,
300                    $this->getLanguage()
301                );
302        }
303    }
304
305    /**
306     * Load namespaces from the provided request and return them as an array. This also performs
307     * validation, ensuring that only valid namespaces are returned.
308     *
309     * @param WebRequest $req The request
310     * @return array An array of namespace IDs
311     */
312    private function loadNamespacesFromRequest( WebRequest $req ): array {
313        $validNamespaces = $this->namespaceInfo->getValidNamespaces();
314
315        return array_map(
316            'intval', array_filter(
317                explode( self::NAMESPACE_LIST_SEPARATOR, $req->getText( "namespace" ) ),
318                static function ( $ns ) use ( $validNamespaces ) {
319                    return is_numeric( $ns ) && in_array( intval( $ns ), $validNamespaces );
320                }
321            )
322        );
323    }
324
325    /**
326     * Does the user have the appropriate permissions and have they enabled in preferences?
327     * Adapted from MediaWiki\CheckUser\Api\Rest\Handler\AbstractTemporaryAccountHandler::checkPermissions
328     *
329     * @param User $currentUser
330     *
331     * @throws PermissionsError if the user does not have the 'checkuser-temporary-account' right
332     * @throws ErrorPageError if the user has not enabled the 'checkuser-temporary-account-enabled' preference
333     */
334    private function assertUserCanAccessTemporaryAccounts( User $currentUser ) {
335        if (
336            !$currentUser->isAllowed( 'checkuser-temporary-account-no-preference' )
337        ) {
338            if (
339                !$currentUser->isAllowed( 'checkuser-temporary-account' )
340            ) {
341                throw new PermissionsError( 'checkuser-temporary-account' );
342            }
343            if (
344                !$this->userOptionsLookup->getOption(
345                    $currentUser,
346                    'checkuser-temporary-account-enable'
347                )
348            ) {
349                throw new ErrorPageError(
350                    $this->msg( 'checkuser-ip-contributions-permission-error-title' ),
351                    $this->msg( 'checkuser-ip-contributions-permission-error-description' )
352                );
353            }
354        }
355    }
356
357    /**
358     * Prompt for a username or IP address.
359     *
360     * @param NukeContext $context
361     */
362    public function showPromptForm( NukeContext $context ): void {
363        $this->getUIRenderer( $context )
364            ->showPromptForm();
365    }
366
367    /**
368     * Display the prompt form and a list of pages to delete.
369     *
370     * @param NukeContext $context
371     */
372    public function showListForm( NukeContext $context ): void {
373        // Check for temporary accounts, if applicable.
374        $tempAccounts = [];
375        if (
376            $this->checkUserTemporaryAccountsByIPLookup &&
377            IPUtils::isValid( $context->getTarget() )
378        ) {
379            // if the target is an ip address and temp account lookup is available,
380            // list pages created by the ip user or by temp accounts associated with the ip address
381            $this->assertUserCanAccessTemporaryAccounts( $this->getUser() );
382            $tempAccounts = $this->getTempAccounts( $context );
383        }
384
385        // Get list of pages to show the user.
386        $pages = $this->getNewPages( $context, $tempAccounts );
387
388        $this->getUIRenderer( $context )
389            ->showListForm( $pages );
390    }
391
392    /**
393     * Display a page confirming all pages to be deleted.
394     *
395     * @param NukeContext $context
396     *
397     * @return void
398     */
399    public function showConfirmForm( NukeContext $context ): void {
400        $this->getUIRenderer( $context )
401            ->showConfirmForm();
402    }
403
404    /**
405     * Show the result page, showing what pages were deleted and what pages were skipped by the
406     * user.
407     *
408     * @param NukeContext $context
409     *   deletion. Can be either `"job"` to indicate that the page was queued for deletion, a
410     *   {@link Status} to indicate if the page was successfully deleted, or `false` if the user
411     *   did not select the page for deletion.
412     * @param (Status|string|boolean)[] $deletedPageStatuses The status for each page queued for
413     * @return void
414     */
415    public function showResultPage( NukeContext $context, array $deletedPageStatuses ): void {
416        $this->getUIRenderer( $context )
417            ->showResultPage( $deletedPageStatuses );
418    }
419
420    /**
421     * Gets a list of new pages by the specified user or everyone when none is specified.
422     *
423     * @param NukeContext $context
424     * @param string[] $tempAccounts Temporary accounts to search for. This is passed directly
425     *   instead of through context to ensure permissions checks happen first.
426     *
427     * @return array{0:Title,1:string|false}[]
428     */
429    protected function getNewPages( NukeContext $context, array $tempAccounts = [] ): array {
430        $dbr = $this->dbProvider->getReplicaDatabase();
431
432        $nukeMaxAge = $context->getNukeMaxAge();
433
434        $min = $context->getDateFrom();
435        if ( !$min || $min->getTimestamp() < time() - $nukeMaxAge ) {
436            // Requested $min is way too far in the past (or null). Set it to the earliest possible
437            // value.
438            $min = time() - $nukeMaxAge;
439        } else {
440            $min = $min->getTimestamp();
441        }
442
443        $max = $context->getDateTo();
444        if ( $max ) {
445            // Increment by 1 day to include all edits from that day.
446            $max = ( clone $max )
447                ->modify( "+1 day" )
448                ->getTimestamp();
449        }
450        // $min and $max are int|null here.
451
452        if ( $max && $max < $min ) {
453            // Impossible range. Skip the query and fail gracefully.
454            return [];
455        }
456        if ( $min > time() ) {
457            // Improbable range (since revisions cannot be in the future).
458            // Skip the query and fail gracefully.
459            return [];
460        }
461        $maxPossibleDate = ( new DateTime() )
462            ->modify( "+1 day" )
463            ->getTimestamp();
464        if ( $max > $maxPossibleDate ) {
465            // Truncate to the current day, since there shouldn't be any future revisions.
466            $max = $maxPossibleDate;
467        }
468
469        $target = $context->getTarget();
470        if ( $target ) {
471            // Enable revision table searches only when a target has been specified.
472            // Running queries on the revision table when there's no actor causes timeouts, since
473            // the entirety of the `page` table needs to be scanned. (T380846)
474            $nukeQueryBuilder = new NukeQueryBuilder(
475                $dbr,
476                $this->getConfig(),
477                $this->namespaceInfo,
478                $this->contentLanguage,
479                NukeQueryBuilder::TABLE_REVISION
480            );
481        } else {
482            // Switch to `recentchanges` table searching when running an all-user search. (T380846)
483            $nukeQueryBuilder = new NukeQueryBuilder(
484                $dbr,
485                $this->getConfig(),
486                $this->namespaceInfo,
487                $this->contentLanguage,
488                NukeQueryBuilder::TABLE_RECENTCHANGES
489            );
490        }
491
492        // Follow the `$wgNukeMaxAge` config variable, or the user-specified minimum date.
493        $nukeQueryBuilder->filterFromTimestamp( $min );
494
495        // Follow the user-specified maximum date, if applicable.
496        if ( $max ) {
497            $nukeQueryBuilder->filterToTimestamp( $max );
498        }
499
500        // Limit the number of rows that can be returned by the query.
501        $limit = $context->getLimit();
502        $nukeQueryBuilder->limit( $limit );
503
504        // Filter by actors, if applicable.
505        $nukeQueryBuilder->filterActor( array_filter( [ $target, ...$tempAccounts ] ) );
506
507        // Filter by namespace, if applicable
508        $namespaces = $context->getNamespaces();
509        $nukeQueryBuilder->filterNamespaces( $namespaces );
510
511        // Filter by pattern, if applicable
512        $pattern = $context->getPattern();
513        $nukeQueryBuilder->filterPattern(
514            $pattern,
515            $namespaces
516        );
517
518        $result = $nukeQueryBuilder
519            ->build()
520            ->caller( __METHOD__ )
521            ->fetchResultSet();
522        /** @var array{0:Title,1:string|false}[] $pages */
523        $pages = [];
524        foreach ( $result as $row ) {
525            $pages[] = [
526                Title::makeTitle( $row->page_namespace, $row->page_title ),
527                $row->actor_name
528            ];
529        }
530
531        // Allows other extensions to provide pages to be mass-deleted that
532        // don't use the revision table the way mediawiki-core does.
533        if ( $namespaces ) {
534            foreach ( $namespaces as $namespace ) {
535                $this->getNukeHookRunner()->onNukeGetNewPages(
536                    $target,
537                    $pattern,
538                    $namespace,
539                    $limit,
540                    $pages
541                );
542            }
543        } else {
544            $this->getNukeHookRunner()->onNukeGetNewPages(
545                $target,
546                $pattern,
547                null,
548                $limit,
549                $pages
550            );
551        }
552
553        // Re-enforcing the limit *after* the hook because other extensions
554        // may add and/or remove pages. We need to make sure we don't end up
555        // with more pages than $limit.
556        if ( count( $pages ) > $limit ) {
557            $pages = array_slice( $pages, 0, $limit );
558        }
559
560        return $pages;
561    }
562
563    /**
564     * Does the actual deletion of the pages.
565     *
566     * @return array An associative array of statuses (or the string "job") keyed by the page title
567     * @throws PermissionsError
568     */
569    protected function doDelete( NukeContext $context ): array {
570        $statuses = [];
571        $jobs = [];
572        $user = $this->getUser();
573
574        $reason = $context->getDeleteReason();
575        $localRepo = $this->repoGroup->getLocalRepo();
576        foreach ( $context->getPages() as $page ) {
577            $title = Title::newFromText( $page );
578
579            $deletionResult = false;
580            if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) {
581                $statuses[$title->getPrefixedDBkey()] = $deletionResult ?
582                    Status::newGood() :
583                    Status::newFatal(
584                        $this->msg( 'nuke-not-deleted' )
585                    );
586                continue;
587            }
588
589            $permission_errors = $this->permissionManager->getPermissionErrors( 'delete', $user, $title );
590
591            if ( $permission_errors !== [] ) {
592                throw new PermissionsError( 'delete', $permission_errors );
593            }
594
595            $file = $title->getNamespace() === NS_FILE ? $localRepo->newFile( $title ) : false;
596            if ( $file ) {
597                // Must be passed by reference
598                $oldimage = null;
599                $status = FileDeleteForm::doDelete(
600                    $title,
601                    $file,
602                    $oldimage,
603                    $reason,
604                    false,
605                    $user
606                );
607            } else {
608                $job = new DeletePageJob( [
609                    'namespace' => $title->getNamespace(),
610                    'title' => $title->getDBKey(),
611                    'reason' => $reason,
612                    'userId' => $user->getId(),
613                    'wikiPageId' => $title->getId(),
614                    'suppress' => false,
615                    'tags' => '["nuke"]',
616                    'logsubtype' => 'delete',
617                ] );
618                $jobs[] = $job;
619                $status = 'job';
620            }
621
622            $statuses[$title->getPrefixedDBkey()] = $status;
623        }
624
625        if ( $jobs ) {
626            $this->jobQueueGroup->push( $jobs );
627        }
628
629        return $statuses;
630    }
631
632    /**
633     * Return an array of subpages beginning with $search that this special page will accept.
634     *
635     * @param string $search Prefix to search for
636     * @param int $limit Maximum number of results to return (usually 10)
637     * @param int $offset Number of results to skip (usually 0)
638     * @return string[] Matching subpages
639     */
640    public function prefixSearchSubpages( $search, $limit, $offset ) {
641        $search = $this->userNameUtils->getCanonical( $search );
642        if ( !$search ) {
643            // No prefix suggestion for invalid user
644            return [];
645        }
646
647        // Autocomplete subpage as user list - public to allow caching
648        return $this->userNamePrefixSearch
649            ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
650    }
651
652    /**
653     * Group Special:Nuke with pagetools
654     *
655     * @codeCoverageIgnore
656     * @return string
657     */
658    protected function getGroupName() {
659        return 'pagetools';
660    }
661
662    private function getNukeHookRunner(): NukeHookRunner {
663        $this->hookRunner ??= new NukeHookRunner( $this->getHookContainer() );