Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
33.79% |
49 / 145 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
SignatureValidator | |
33.79% |
49 / 145 |
|
50.00% |
4 / 8 |
688.07 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
validateSignature | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
306 | |||
applyPreSaveTransform | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
checkLintErrors | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
checkUserLinks | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
8 | |||
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\ParserOutputFlags; |
27 | use MediaWiki\Parser\Parsoid\Config\PageConfigFactory; |
28 | use MediaWiki\Revision\MutableRevisionRecord; |
29 | use MediaWiki\Revision\SlotRecord; |
30 | use MediaWiki\SpecialPage\SpecialPage; |
31 | use MediaWiki\SpecialPage\SpecialPageFactory; |
32 | use MediaWiki\Title\Title; |
33 | use MediaWiki\Title\TitleFactory; |
34 | use MediaWiki\User\UserIdentity; |
35 | use MessageLocalizer; |
36 | use OOUI\ButtonWidget; |
37 | use ParserFactory; |
38 | use ParserOptions; |
39 | use Wikimedia\Parsoid\Parsoid; |
40 | use WikitextContent; |
41 | |
42 | /** |
43 | * @since 1.35 |
44 | */ |
45 | class 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 | } |