Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.39% covered (success)
98.39%
183 / 186
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialLintErrors
98.39% covered (success)
98.39%
183 / 186
75.00% covered (warning)
75.00%
9 / 12
33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 showFilterForm
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
3
 cleanTitle
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 displayError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 findNamespaces
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 execute
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
11
 displayList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 displaySearchPage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 showCategoryListings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildCategoryList
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubpagesForPrefixSearch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 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 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Linter;
22
23use HTMLForm;
24use MediaWiki\Cache\LinkCache;
25use MediaWiki\Html\Html;
26use MediaWiki\Output\OutputPage;
27use MediaWiki\Permissions\PermissionManager;
28use MediaWiki\Request\WebRequest;
29use MediaWiki\SpecialPage\SpecialPage;
30use MediaWiki\Title\MalformedTitleException;
31use MediaWiki\Title\NamespaceInfo;
32use MediaWiki\Title\TitleParser;
33
34class SpecialLintErrors extends SpecialPage {
35
36    private NamespaceInfo $namespaceInfo;
37    private TitleParser $titleParser;
38    private LinkCache $linkCache;
39    private PermissionManager $permissionManager;
40    private CategoryManager $categoryManager;
41    private TotalsLookup $totalsLookup;
42
43    /**
44     * @var string|null
45     */
46    private $category;
47
48    /**
49     * @param NamespaceInfo $namespaceInfo
50     * @param TitleParser $titleParser
51     * @param LinkCache $linkCache
52     * @param PermissionManager $permissionManager
53     * @param CategoryManager $categoryManager
54     * @param TotalsLookup $totalsLookup
55     */
56    public function __construct(
57        NamespaceInfo $namespaceInfo,
58        TitleParser $titleParser,
59        LinkCache $linkCache,
60        PermissionManager $permissionManager,
61        CategoryManager $categoryManager,
62        TotalsLookup $totalsLookup
63    ) {
64        parent::__construct( 'LintErrors' );
65        $this->namespaceInfo = $namespaceInfo;
66        $this->titleParser = $titleParser;
67        $this->linkCache = $linkCache;
68        $this->permissionManager = $permissionManager;
69        $this->categoryManager = $categoryManager;
70        $this->totalsLookup = $totalsLookup;
71    }
72
73    /**
74     * @param string $titleLabel
75     */
76    protected function showFilterForm( $titleLabel ) {
77        $selectOptions = [
78            (string)$this->msg( 'linter-form-exact-match' )->escaped() => true,
79            (string)$this->msg( 'linter-form-prefix-match' )->escaped() => false,
80        ];
81        $namespaces = $this->getContext()->getRequest()->getVal( "wpNamespaceRestrictions" );
82        $fields = [
83            'NamespaceRestrictions' => [
84                'type' => 'namespacesmultiselect',
85                'label' => $this->msg( 'linter-form-namespace' )->text(),
86                'exists' => true,
87                'cssclass' => 'mw-block-partial-restriction',
88                'default' => $namespaces,
89                'input' => [ 'autocomplete' => false ]
90            ],
91            'titlefield' => [
92                'type' => 'title',
93                'name' => $titleLabel,
94                'label-message' => 'linter-form-title-prefix',
95                'exists' => true,
96                'required' => false
97            ],
98            'exactmatchradio' => [
99                'type' => 'radio',
100                'name' => 'exactmatch',
101                'options' => $selectOptions,
102                'label-message' => 'linter-form-exact-or-prefix',
103                'default' => true
104            ]
105        ];
106
107        $enableUserInterfaceTagAndTemplateStage =
108            $this->getConfig()->get( 'LinterUserInterfaceTagAndTemplateStage' );
109        if ( $enableUserInterfaceTagAndTemplateStage ) {
110            $selectTemplateOptions = [
111                (string)$this->msg( 'linter-form-template-option-all' )->escaped() => 'all',
112                (string)$this->msg( 'linter-form-template-option-with' )->escaped() => 'with',
113                (string)$this->msg( 'linter-form-template-option-without' )->escaped() => 'without',
114            ];
115            $htmlTags = new HtmlTags( $this );
116            $tagAndTemplateFields = [
117                'tag' => [
118                    'type' => 'select',
119                    'name' => 'tag',
120                    'label-message' => 'linter-form-tag',
121                    'options' => $htmlTags->getAllowedHTMLTags()
122                ],
123                'template' => [
124                    'type' => 'select',
125                    'name' => 'template',
126                    'label-message' => 'linter-form-template',
127                    'options' => $selectTemplateOptions
128                ]
129            ];
130            $fields = array_merge( $fields, $tagAndTemplateFields );
131        }
132
133        $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
134        $form->setWrapperLegend( true );
135        if ( $this->category !== null ) {
136            $form->addHeaderHtml( $this->msg( "linter-category-{$this->category}-desc" )->parse() );
137        }
138        $form->setMethod( 'get' );
139        $form->prepareForm()->displayForm( false );
140    }
141
142    /**
143     * cleanTitle parses a title and handles a malformed titles, namespaces that are mismatched
144     * and exact title searches that find no matching records, and produce appropriate error messages
145     *
146     * @param string $title
147     * @param array $namespaces
148     * @return array
149     */
150    public function cleanTitle( string $title, $namespaces ): array {
151        // Check all titles for malformation regardless of exact match or prefix match
152        try {
153            $titleElements = $this->titleParser->parseTitle( $title );
154        } catch ( MalformedTitleException  $e ) {
155            return [ 'titlefield' => null, 'error' => 'linter-invalid-title' ];
156        }
157
158        // The drop-down namespace defaults to 'all' which is returned as a null, indicating match all namespaces.
159        // If 'main' is selected in the drop-down, int 0 is returned. Other namespaces are returned as int values > 0.
160        //
161        // If the user does not specify a namespace in the title text box, parseTitle sets it to int 0 as the default.
162        // If the user entered ':' (main) namespace as the namespace prefix of a title such as ":MyPageTitle",
163        // parseTitle will also return int 0 as the namespace. Other valid system namespaces entered as prefixes
164        // in the title text box are returned by parseTitle as int values > 0.
165        // To determine if the user entered the ':' (main) namespace when int 0 is returned, a separate check for
166        // the substring ':' at offset 0 must be performed.
167
168        $titleNamespace = $titleElements->getNamespace();
169        // Determine if the user entered ':' (resolves to main) as the namespace part of the title,
170        // or was it was set by default by parseTitle() to 0, but the user intended to search across 'all' namespaces.
171        if ( $titleNamespace === 0 && $title[0] !== ':' ) {
172            $titleNamespace = null;
173        }
174
175        if ( $namespaces && $titleNamespace !== null && !in_array( $titleNamespace, $namespaces ) ) {
176            // Show the namespace mismatch error if the namespaces specified in drop-down and title text do not match.
177            return [ 'titlefield' => null, 'error' => 'linter-namespace-mismatch' ];
178        }
179
180        // If no namespaces are selected (null), return the namespace from the title text
181        $namespaces = $namespaces ?: [ $titleNamespace ];
182
183        return [ 'titlefield' => $titleElements->getDBkey(), 'namespace' => $namespaces ];
184    }
185
186    /**
187     * @param OutputPage $out
188     * @param string|null $message
189     */
190    private function displayError( $out, $message ) {
191        $out->addHTML(
192            Html::element( 'span', [ 'class' => 'error' ],
193                $this->msg( $message )->text() )
194        );
195    }
196
197    /**
198     * Extract namespace settings from the request object,
199     * returning an array of namespace id numbers
200     *
201     * @param WebRequest $request
202     * @return array
203     */
204    protected function findNamespaces( $request ) {
205        $namespaces = [];
206        $activeNamespaces = array_keys(
207            $this->namespaceInfo->getCanonicalNamespaces()
208        );
209        // Remove -2 = "media" and -1 = "Special" namespace elements
210        $activeNamespaces = array_filter( $activeNamespaces,
211            static function ( $x ) {
212                return $x >= 0;
213            }
214        );
215        if ( $request->getCheck( 'wpNamespaceRestrictions' ) ) {
216            $namespaceRequestValues = $request->getRawVal( 'wpNamespaceRestrictions' );
217            $namespaceIDs = array_map( 'intval', explode( "\n", $namespaceRequestValues ) );
218            // Security measure: only allow active namespace IDs to reach the query
219            $namespaces = array_values( array_intersect( $activeNamespaces, $namespaceIDs ) );
220        }
221        return $namespaces;
222    }
223
224    /**
225     * @param string|null $subPage
226     */
227    public function execute( $subPage ) {
228        $request = $this->getRequest();
229        $out = $this->getOutput();
230
231        $params = $request->getQueryValues();
232
233        $this->setHeaders();
234        $this->outputHeader( $subPage || isset( $params[ 'titlesearch' ] ) ? 'disable-summary' : '' );
235
236        $namespaces = $this->findNamespaces( $request );
237
238        $exactMatch = $request->getBool( 'exactmatch', true );
239        $tagName = $this->getRequest()->getText( 'tag' );
240        // map command line tag name through an associative array to protect request from an SQL injection security risk
241        $htmlTags = new HtmlTags( $this );
242        $allowedHtmlTags = $htmlTags->getAllowedHTMLTags();
243        $tag = $allowedHtmlTags[ $tagName ] ?? 'all';
244        $template = $this->getRequest()->getText( 'template' );
245
246        // If the request contains a 'titlesearch' parameter, then the user entered a page title
247        // or just the first few characters of the title. They also may have entered the first few characters
248        // of a custom namespace (just the text before a ':') to search for and pressed the associated Submit button.
249        // Added the pageback parameter to inform the code that the '<- Special:LintErrors' link had been used to allow
250        // the UI to redisplay with previous form values, instead of just resubmitting the query.
251        if ( $subPage === null && isset( $params[ 'titlesearch' ] ) && !isset( $params[ 'pageback'] ) ) {
252            unset( $params[ 'title' ] );
253            $params = array_merge( [ 'pageback' => true ], $params );
254            $out->addBacklinkSubtitle( $this->getPageTitle(), $params );
255
256            $title = $request->getText( 'titlesearch' );
257            $titleSearch = $this->cleanTitle( $title, $namespaces );
258
259            if ( $titleSearch[ 'titlefield' ] !== null ) {
260                $out->setPageTitleMsg( $this->msg( 'linter-prefix-search-subpage', $titleSearch[ 'titlefield' ] ) );
261
262                $pager = new LintErrorsPager(
263                    $this->getContext(),
264                    $this->categoryManager,
265                    $this->linkCache,
266                    $this->getLinkRenderer(),
267                    $this->permissionManager,
268                    null,
269                    $namespaces,
270                    $exactMatch, $titleSearch[ 'titlefield' ], $template, $tag
271                );
272                $out->addParserOutput( $pager->getFullOutput() );
273            } else {
274                $this->displayError( $out, $titleSearch[ 'error' ] );
275            }
276            return;
277        }
278
279        if ( in_array( $subPage, array_merge(
280            $this->categoryManager->getVisibleCategories(),
281            $this->categoryManager->getInvisibleCategories()
282        ) ) ) {
283            $this->category = $subPage;
284        }
285
286        if ( !$this->category ) {
287            $this->addHelpLink( 'Help:Extension:Linter' );
288            $this->showCategoryListings();
289        } else {
290            $this->addHelpLink( "Help:Lint_errors/{$this->category}" );
291            $out->setPageTitleMsg(
292                $this->msg( 'linterrors-subpage',
293                    $this->msg( "linter-category-{$this->category}" )->text()
294                )
295            );
296            $out->addBacklinkSubtitle( $this->getPageTitle() );
297
298            $title = $request->getText( 'titlecategorysearch' );
299            // For category-based searches, allow an undefined title to display all records
300            if ( $title === '' ) {
301                $titleCategorySearch = [ 'titlefield' => '', 'namespace' => $namespaces, 'pageid' => null ];
302            } else {
303                $titleCategorySearch = $this->cleanTitle( $title, $namespaces );
304            }
305
306            if ( $titleCategorySearch[ 'titlefield' ] !== null ) {
307                $this->showFilterForm( 'titlecategorysearch' );
308                $pager = new LintErrorsPager(
309                    $this->getContext(),
310                    $this->categoryManager,
311                    $this->linkCache,
312                    $this->getLinkRenderer(),
313                    $this->permissionManager,
314                    $this->category,
315                    $namespaces,
316                    $exactMatch, $titleCategorySearch[ 'titlefield' ], $template, $tag
317                );
318                $out->addParserOutput( $pager->getFullOutput() );
319            } else {
320                $this->displayError( $out, $titleCategorySearch[ 'error' ] );
321            }
322        }
323    }
324
325    /**
326     * @param string $priority
327     * @param int[] $totals name => count
328     * @param string[] $categories
329     */
330    private function displayList( $priority, $totals, array $categories ) {
331        $out = $this->getOutput();
332        $msgName = 'linter-heading-' . $priority . '-priority';
333        $out->addHTML( Html::element( 'h2', [], $this->msg( $msgName )->text() ) );
334        $out->addHTML( $this->buildCategoryList( $categories, $totals ) );
335    }
336
337    /**
338     */
339    private function displaySearchPage() {
340        $out = $this->getOutput();
341        $out->addHTML( Html::element( 'h2', [],
342            $this->msg( "linter-lints-prefix-search-page-desc" )->text() ) );
343        $this->showFilterForm( 'titlesearch' );
344    }
345
346    private function showCategoryListings() {
347        $totals = $this->totalsLookup->getTotals();
348
349        // Display lint issues by priority
350        $this->displayList( 'high', $totals, $this->categoryManager->getHighPriority() );
351        $this->displayList( 'medium', $totals, $this->categoryManager->getMediumPriority() );
352        $this->displayList( 'low', $totals, $this->categoryManager->getLowPriority() );
353
354        $this->displaySearchPage();
355    }
356
357    /**
358     * @param string[] $cats
359     * @param int[] $totals name => count
360     * @return string
361     */
362    private function buildCategoryList( array $cats, array $totals ) {
363        $linkRenderer = $this->getLinkRenderer();
364        $html = Html::openElement( 'ul' ) . "\n";
365        foreach ( $cats as $cat ) {
366            $html .= Html::rawElement( 'li', [], $linkRenderer->makeKnownLink(
367                $this->getPageTitle( $cat ),
368                $this->msg( "linter-category-$cat" )->text()
369            ) . ' ' . Html::element( 'bdi', [],
370                $this->msg( "linter-numerrors" )->numParams( $totals[$cat] )->text()
371            ) ) . "\n";
372        }
373        $html .= Html::closeElement( 'ul' );
374
375        return $html;
376    }
377
378    /** @inheritDoc */
379    public function getGroupName() {
380        return 'maintenance';
381    }
382
383    /**
384     * @return string[]
385     */
386    protected function getSubpagesForPrefixSearch() {
387        return $this->categoryManager->getVisibleCategories();
388    }
389
390}