Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
47.48% |
66 / 139 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
SignatureValidator | |
47.48% |
66 / 139 |
|
50.00% |
4 / 8 |
352.51 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
validateSignature | |
34.04% |
16 / 47 |
|
0.00% |
0 / 1 |
79.56 | |||
applyPreSaveTransform | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
checkLintErrors | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
checkUserLinks | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
9 | |||
checkLineBreaks | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLintErrorLocation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLintErrorDetails | |
0.00% |
0 / 32 |
|
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 | |
21 | namespace MediaWiki\Preferences; |
22 | |
23 | use MediaWiki\Config\ServiceOptions; |
24 | use MediaWiki\Html\Html; |
25 | use MediaWiki\MainConfigNames; |
26 | use MediaWiki\Parser\ParserFactory; |
27 | use MediaWiki\Parser\ParserOptions; |
28 | use MediaWiki\Parser\ParserOutputFlags; |
29 | use MediaWiki\Parser\ParserOutputLinkTypes; |
30 | use MediaWiki\Parser\Parsoid\LintErrorChecker; |
31 | use MediaWiki\SpecialPage\SpecialPage; |
32 | use MediaWiki\SpecialPage\SpecialPageFactory; |
33 | use MediaWiki\Title\TitleFactory; |
34 | use MediaWiki\User\User; |
35 | use MediaWiki\User\UserIdentity; |
36 | use MessageLocalizer; |
37 | use OOUI\ButtonWidget; |
38 | |
39 | /** |
40 | * @since 1.35 |
41 | */ |
42 | class 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 | } |