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