Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.64% covered (warning)
50.64%
79 / 156
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureValidator
50.64% covered (warning)
50.64%
79 / 156
37.50% covered (danger)
37.50%
3 / 8
325.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 validateSignature
34.04% covered (danger)
34.04%
16 / 47
0.00% covered (danger)
0.00%
0 / 1
79.56
 applyPreSaveTransform
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 checkLintErrors
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
4.01
 checkUserLinks
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 checkLineBreaks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLintErrorLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLintErrorDetails
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
240
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\Preferences;
22
23use ExtensionRegistry;
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\Html\Html;
26use MediaWiki\MainConfigNames;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Parser\ParserOutputFlags;
29use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
30use MediaWiki\Revision\MutableRevisionRecord;
31use MediaWiki\Revision\SlotRecord;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\SpecialPage\SpecialPageFactory;
34use MediaWiki\Title\Title;
35use MediaWiki\Title\TitleFactory;
36use MediaWiki\User\UserIdentity;
37use MessageLocalizer;
38use OOUI\ButtonWidget;
39use ParserFactory;
40use ParserOptions;
41use Wikimedia\Parsoid\Parsoid;
42use WikitextContent;
43
44/**
45 * @since 1.35
46 */
47class SignatureValidator {
48
49    /** @var array Made public for use in services */
50    public const CONSTRUCTOR_OPTIONS = [
51        MainConfigNames::SignatureAllowedLintErrors,
52        MainConfigNames::VirtualRestConfig,
53    ];
54
55    /** @var UserIdentity */
56    private $user;
57    /** @var MessageLocalizer|null */
58    private $localizer;
59    /** @var ParserOptions */
60    private $popts;
61    /** @var ParserFactory */
62    private $parserFactory;
63    private Parsoid $parsoid;
64    private PageConfigFactory $pageConfigFactory;
65    /** @var ServiceOptions */
66    private $serviceOptions;
67    /** @var SpecialPageFactory */
68    private $specialPageFactory;
69    /** @var TitleFactory */
70    private $titleFactory;
71    private ExtensionRegistry $extensionRegistry;
72
73    /**
74     * @param ServiceOptions $options
75     * @param UserIdentity $user
76     * @param ?MessageLocalizer $localizer
77     * @param ParserOptions $popts
78     * @param ParserFactory $parserFactory
79     * @param Parsoid $parsoid
80     * @param PageConfigFactory $pageConfigFactory
81     * @param SpecialPageFactory $specialPageFactory
82     * @param TitleFactory $titleFactory
83     * @param ExtensionRegistry $extensionRegistry
84     */
85    public function __construct(
86        ServiceOptions $options,
87        UserIdentity $user,
88        ?MessageLocalizer $localizer,
89        ParserOptions $popts,
90        ParserFactory $parserFactory,
91        Parsoid $parsoid,
92        PageConfigFactory $pageConfigFactory,
93        SpecialPageFactory $specialPageFactory,
94        TitleFactory $titleFactory,
95        ExtensionRegistry $extensionRegistry
96    ) {
97        $this->user = $user;
98        $this->localizer = $localizer;
99        $this->popts = $popts;
100        $this->parserFactory = $parserFactory;
101        $this->parsoid = $parsoid;
102        $this->pageConfigFactory = $pageConfigFactory;
103        // Configuration
104        $this->serviceOptions = $options;
105        $this->serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
106        // TODO SpecialPage::getTitleFor should also be available via SpecialPageFactory
107        $this->specialPageFactory = $specialPageFactory;
108        $this->titleFactory = $titleFactory;
109        $this->extensionRegistry = $extensionRegistry;
110    }
111
112    /**
113     * @param string $signature Signature before PST
114     * @return string[]|bool If localizer is defined: List of errors, as HTML (empty array for no errors)
115     *   If localizer is not defined: True if there are errors, false if there are no errors
116     */
117    public function validateSignature( string $signature ) {
118        $pstSignature = $this->applyPreSaveTransform( $signature );
119        if ( $pstSignature === false ) {
120            // Return early because the rest of the validation uses wikitext parsing, which requires
121            // the pre-save transform to be applied first, and we just found out that the result of the
122            // pre-save transform would require *another* pre-save transform, which is crazy
123            if ( $this->localizer ) {
124                return [ $this->localizer->msg( 'badsigsubst' )->parse() ];
125            }
126            return true;
127        }
128
129        $pstWasApplied = false;
130        if ( $pstSignature !== $signature ) {
131            $pstWasApplied = true;
132            $signature = $pstSignature;
133        }
134
135        $errors = $this->localizer ? [] : false;
136
137        $lintErrors = $this->checkLintErrors( $signature );
138        if ( $lintErrors ) {
139            $messages = '';
140
141            foreach ( $lintErrors as $error ) {
142                if ( !$this->localizer ) {
143                    $errors = true;
144                    break;
145                }
146
147                $details = $this->getLintErrorDetails( $error );
148                $location = $this->getLintErrorLocation( $error );
149                // THESE MESSAGE IDS SHOULD BE KEPT IN SYNC WITH
150                // those declared in Extension:Linter -- in particular
151                // there should be a linterror-<cat> declared here for every
152                // linter-pager-<cat>-details declared in Linter's qqq.json.
153                // T360809: this redundancy should be eventually eliminated
154
155                // Messages used here:
156                // * linterror-bogus-image-options
157                // * linterror-deletable-table-tag
158                // * linterror-fostered
159                // * linterror-html5-misnesting
160                // * linterror-inline-media-caption
161                // * linterror-large-tables
162                // * linterror-misc-tidy-replacement-issues
163                // * linterror-misnested-tag
164                // * linterror-missing-end-tag
165                // * linterror-missing-end-tag-in-heading
166                // * linterror-missing-image-alt-text
167                // * linterror-multi-colon-escape
168                // * linterror-multiline-html-table-in-list
169                // * linterror-multiple-unclosed-formatting-tags
170                // * linterror-night-mode-unaware-background-color
171                // * linterror-obsolete-tag
172                // * linterror-pwrap-bug-workaround
173                // * linterror-self-closed-tag
174                // * linterror-stripped-tag
175                // * linterror-tidy-font-bug
176                // * linterror-tidy-whitespace-bug
177                // * linterror-unclosed-quotes-in-heading
178                // * linterror-wikilink-in-extlink
179                $label = $this->localizer->msg( "linterror-{$error['type']}" )->parse();
180                $docsLink = new ButtonWidget( [
181                    'href' =>
182                        "https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Lint_errors/{$error['type']}",
183                    'target' => '_blank',
184                    'label' => $this->localizer->msg( 'prefs-signature-error-details' )->text(),
185                ] );
186
187                // If pre-save transform was applied (i.e., the signature has 'subst:' syntax),
188                // error locations will be incorrect, because Parsoid can't expand templates.
189                // Don't display them.
190                $encLocation = $pstWasApplied ? null : json_encode( $location );
191
192                $messages .= Html::rawElement(
193                    'li',
194                    [ 'data-mw-lint-error-location' => $encLocation ],
195                    $label . $this->localizer->msg( 'colon-separator' )->escaped() .
196                        $details . ' ' . $docsLink
197                );
198            }
199
200            if ( $messages && $this->localizer ) {
201                $errors[] = $this->localizer->msg( 'badsightml' )->parse() .
202                    Html::rawElement( 'ol', [], $messages );
203            }
204        }
205
206        if ( !$this->checkUserLinks( $signature ) ) {
207            if ( $this->localizer ) {
208                $userText = wfEscapeWikiText( $this->user->getName() );
209                $linkWikitext = $this->localizer->msg( 'signature', $userText, $userText )->inContentLanguage()->text();
210                $errors[] = $this->localizer->msg( 'badsiglinks', wfEscapeWikiText( $linkWikitext ) )->parse();
211            } else {
212                $errors = true;
213            }
214        }
215
216        if ( !$this->checkLineBreaks( $signature ) ) {
217            if ( $this->localizer ) {
218                $errors[] = $this->localizer->msg( 'badsiglinebreak' )->parse();
219            } else {
220                $errors = true;
221            }
222        }
223
224        return $errors;
225    }
226
227    /**
228     * @param string $signature Signature before PST
229     * @return string|false Signature with PST applied, or false if applying PST yields wikitext that
230     *     would change if PST was applied again
231     */
232    protected function applyPreSaveTransform( string $signature ) {
233        // This may be called by the Parser when it's displaying a signature, so we need a new instance
234        $parser = $this->parserFactory->getInstance();
235
236        $pstSignature = $parser->preSaveTransform(
237            $signature,
238            SpecialPage::getTitleFor( 'Preferences' ),
239            $this->user,
240            $this->popts
241        );
242
243        // The signature wikitext contains another '~~~~' or similar (T230652)
244        if ( $parser->getOutput()->getOutputFlag( ParserOutputFlags::USER_SIGNATURE ) ) {
245            return false;
246        }
247
248        // The signature wikitext contains '{{subst:...}}' markup that produces another subst (T230652)
249        $pstPstSignature = $parser->preSaveTransform(
250            $pstSignature,
251            SpecialPage::getTitleFor( 'Preferences' ),
252            $this->user,
253            $this->popts
254        );
255        if ( $pstPstSignature !== $pstSignature ) {
256            return false;
257        }
258
259        return $pstSignature;
260    }
261
262    /**
263     * @param string $signature Signature after PST
264     * @return array Array of error objects returned by Parsoid's lint API (empty array for no errors)
265     */
266    protected function checkLintErrors( string $signature ): array {
267        // Real check for mismatched HTML tags in the *output*.
268        // This has to use Parsoid because PHP Parser doesn't produce this information,
269        // it just fixes up the result quietly.
270
271        $disabled = array_merge(
272            [
273                // Always appears with 'missing-end-tag', we can ignore it to
274                // simplify the error message
275                'multiple-unclosed-formatting-tags',
276            ],
277            $this->serviceOptions->get(
278                MainConfigNames::SignatureAllowedLintErrors
279            )
280        );
281        if ( $this->extensionRegistry->isLoaded( 'Linter' ) ) { // T360809
282            $services = MediaWikiServices::getInstance();
283            $linterCategories = $services->getMainConfig()->get( 'LinterCategories' );
284            foreach ( $linterCategories as $name => $cat ) {
285                if ( $cat['priority'] === 'none' ) {
286                    $disabled[] = $name;
287                }
288            }
289        }
290        $disabled = array_unique( $disabled );
291
292        $page = Title::newMainPage();
293        $fakeRevision = new MutableRevisionRecord( $page );
294        $fakeRevision->setSlot(
295            SlotRecord::newUnsaved(
296                SlotRecord::MAIN,
297                new WikitextContent( $signature )
298            )
299        );
300
301        return $this->parsoid->wikitext2lint(
302            $this->pageConfigFactory->create( $page, null, $fakeRevision ),
303            [ 'linterOverrides' => [ 'disabled' => $disabled ] ]
304        );
305    }
306
307    /**
308     * @param string $signature Signature after PST
309     * @return bool Whether signature contains required links
310     */
311    protected function checkUserLinks( string $signature ): bool {
312        // This may be called by the Parser when it's displaying a signature, so we need a new instance
313        $parser = $this->parserFactory->getInstance();
314
315        // Check for required links. This one's easier to do with the PHP Parser.
316        $pout = $parser->parse(
317            $signature,
318            SpecialPage::getTitleFor( 'Preferences' ),
319            $this->popts
320        );
321
322        // Checking user or talk links is easy
323        $links = $pout->getLinks();
324        $username = $this->user->getName();
325        if (
326            isset( $links[ NS_USER ][ strtr( $username, ' ', '_' ) ] ) ||
327            isset( $links[ NS_USER_TALK ][ strtr( $username, ' ', '_' ) ] )
328        ) {
329            return true;
330        }
331
332        // Checking the contributions link is harder, because the special page name and the username in
333        // the "subpage parameter" are not normalized for us.
334        $splinks = $pout->getLinksSpecial();
335        foreach ( $splinks as $dbkey => $unused ) {
336            [ $name, $subpage ] = $this->specialPageFactory->resolveAlias( $dbkey );
337            if ( $name === 'Contributions' && $subpage ) {
338                $userTitle = $this->titleFactory->makeTitleSafe( NS_USER, $subpage );
339                if ( $userTitle && $userTitle->getText() === $username ) {
340                    return true;
341                }
342            }
343        }
344
345        return false;
346    }
347
348    /**
349     * @param string $signature Signature after PST
350     * @return bool Whether signature contains no line breaks
351     */
352    protected function checkLineBreaks( string $signature ): bool {
353        return !preg_match( "/[\r\n]/", $signature );
354    }
355
356    // Adapted from the Linter extension
357    private function getLintErrorLocation( array $lintError ): array {
358        return array_slice( $lintError['dsr'], 0, 2 );
359    }
360
361    // Adapted from the Linter extension
362    private function getLintErrorDetails( array $lintError ): string {
363        [ 'type' => $type, 'params' => $params ] = $lintError;
364
365        if ( $type === 'bogus-image-options' && isset( $params['items'] ) ) {
366            $list = array_map( static function ( $in ) {
367                return Html::element( 'code', [], $in );
368            }, $params['items'] );
369            return implode(
370                $this->localizer->msg( 'comma-separator' )->escaped(),
371                $list
372            );
373        } elseif ( $type === 'pwrap-bug-workaround' &&
374            isset( $params['root'] ) &&
375            isset( $params['child'] ) ) {
376            return Html::element( 'code', [],
377                $params['root'] . " > " . $params['child'] );
378        } elseif ( $type === 'tidy-whitespace-bug' &&
379            isset( $params['node'] ) &&
380            isset( $params['sibling'] ) ) {
381            return Html::element( 'code', [],
382                $params['node'] . " + " . $params['sibling'] );
383        } elseif ( $type === 'multi-colon-escape' &&
384            isset( $params['href'] ) ) {
385            return Html::element( 'code', [], $params['href'] );
386        } elseif ( $type === 'multiline-html-table-in-list' ) {
387            /* ancestor and name will be set */
388            return Html::element( 'code', [],
389                $params['ancestorName'] . " > " . $params['name'] );
390        } elseif ( $type === 'misc-tidy-replacement-issues' ) {
391            /* There will be a 'subtype' param to disambiguate */
392            return Html::element( 'code', [], $params['subtype'] );
393        } elseif ( $type === 'missing-end-tag' ) {
394            return Html::element( 'code', [], '</' . $params['name'] . '>' );
395        } elseif ( isset( $params['name'] ) ) {
396            return Html::element( 'code', [], $params['name'] );
397        }
398
399        return '';
400    }
401
402}