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