Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
176 / 176 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
Hooks | |
100.00% |
176 / 176 |
|
100.00% |
8 / 8 |
48 | |
100.00% |
1 / 1 |
getConfig | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
getMatcherFactory | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
validateExtraWrapper | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
getSanitizer | |
100.00% |
68 / 68 |
|
100.00% |
1 / 1 |
10 | |||
onRegistration | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
onParserFirstCallInit | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
onContentHandlerDefaultModelFor | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
onParserClearState | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
handleTag | |
100.00% |
80 / 80 |
|
100.00% |
1 / 1 |
21 | |||
formatTagError | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\TemplateStyles; |
4 | |
5 | /** |
6 | * @file |
7 | * @license GPL-2.0-or-later |
8 | */ |
9 | |
10 | use ContentHandler; |
11 | use ExtensionRegistry; |
12 | use InvalidArgumentException; |
13 | use MapCacheLRU; |
14 | use MediaWiki\Config\Config; |
15 | use MediaWiki\Extension\TemplateStyles\Hooks\HookRunner; |
16 | use MediaWiki\Hook\ParserClearStateHook; |
17 | use MediaWiki\Hook\ParserFirstCallInitHook; |
18 | use MediaWiki\Html\Html; |
19 | use MediaWiki\MediaWikiServices; |
20 | use MediaWiki\Parser\Parser; |
21 | use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook; |
22 | use MediaWiki\Revision\SlotRecord; |
23 | use MediaWiki\Title\Title; |
24 | use PPFrame; |
25 | use Wikimedia\CSS\Grammar\CheckedMatcher; |
26 | use Wikimedia\CSS\Grammar\GrammarMatch; |
27 | use Wikimedia\CSS\Grammar\MatcherFactory; |
28 | use Wikimedia\CSS\Objects\ComponentValue; |
29 | use Wikimedia\CSS\Objects\ComponentValueList; |
30 | use Wikimedia\CSS\Objects\Token; |
31 | use Wikimedia\CSS\Parser\Parser as CSSParser; |
32 | use Wikimedia\CSS\Sanitizer\KeyframesAtRuleSanitizer; |
33 | use Wikimedia\CSS\Sanitizer\MediaAtRuleSanitizer; |
34 | use Wikimedia\CSS\Sanitizer\NamespaceAtRuleSanitizer; |
35 | use Wikimedia\CSS\Sanitizer\PageAtRuleSanitizer; |
36 | use Wikimedia\CSS\Sanitizer\Sanitizer; |
37 | use Wikimedia\CSS\Sanitizer\StylePropertySanitizer; |
38 | use Wikimedia\CSS\Sanitizer\StyleRuleSanitizer; |
39 | use Wikimedia\CSS\Sanitizer\StylesheetSanitizer; |
40 | use Wikimedia\CSS\Sanitizer\SupportsAtRuleSanitizer; |
41 | |
42 | /** |
43 | * TemplateStyles extension hooks |
44 | */ |
45 | class Hooks implements |
46 | ParserFirstCallInitHook, |
47 | ParserClearStateHook, |
48 | ContentHandlerDefaultModelForHook |
49 | { |
50 | |
51 | /** @var MatcherFactory|null */ |
52 | private static $matcherFactory = null; |
53 | |
54 | /** @var Sanitizer[] */ |
55 | private static $sanitizers = []; |
56 | |
57 | /** @var (false|Token[])[] */ |
58 | private static $wrappers = []; |
59 | |
60 | /** |
61 | * @return Config |
62 | * @codeCoverageIgnore |
63 | */ |
64 | public static function getConfig() { |
65 | return MediaWikiServices::getInstance()->getConfigFactory() |
66 | ->makeConfig( 'templatestyles' ); |
67 | } |
68 | |
69 | /** |
70 | * @return MatcherFactory |
71 | * @codeCoverageIgnore |
72 | */ |
73 | private static function getMatcherFactory() { |
74 | if ( !self::$matcherFactory ) { |
75 | self::$matcherFactory = new TemplateStylesMatcherFactory( |
76 | self::getConfig()->get( 'TemplateStylesAllowedUrls' ) |
77 | ); |
78 | } |
79 | return self::$matcherFactory; |
80 | } |
81 | |
82 | /** |
83 | * Validate an extra wrapper-selector |
84 | * @param string $wrapper |
85 | * @return ComponentValue[]|false Representation of the selector, or false on failure |
86 | */ |
87 | private static function validateExtraWrapper( $wrapper ) { |
88 | if ( !isset( self::$wrappers[$wrapper] ) ) { |
89 | $cssParser = CSSParser::newFromString( $wrapper ); |
90 | $components = $cssParser->parseComponentValueList(); |
91 | if ( $cssParser->getParseErrors() ) { |
92 | $match = false; |
93 | } else { |
94 | $match = self::getMatcherFactory()->cssSimpleSelectorSeq() |
95 | ->matchAgainst( $components, [ 'mark-significance' => true ] ); |
96 | } |
97 | self::$wrappers[$wrapper] = $match ? $components->toComponentValueArray() : false; |
98 | } |
99 | return self::$wrappers[$wrapper]; |
100 | } |
101 | |
102 | /** |
103 | * @param string $class Class to limit selectors to |
104 | * @param string|null $extraWrapper Extra selector to limit selectors to |
105 | * @return Sanitizer |
106 | */ |
107 | public static function getSanitizer( $class, $extraWrapper = null ) { |
108 | $key = $extraWrapper !== null ? "$class $extraWrapper" : $class; |
109 | if ( !isset( self::$sanitizers[$key] ) ) { |
110 | $config = self::getConfig(); |
111 | $matcherFactory = self::getMatcherFactory(); |
112 | |
113 | $propertySanitizer = new StylePropertySanitizer( $matcherFactory ); |
114 | $propertySanitizer->setKnownProperties( array_diff_key( |
115 | $propertySanitizer->getKnownProperties(), |
116 | array_flip( $config->get( 'TemplateStylesDisallowedProperties' ) ) |
117 | ) ); |
118 | $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ); |
119 | $hookRunner->onTemplateStylesPropertySanitizer( $propertySanitizer, $matcherFactory ); |
120 | |
121 | $htmlOrBodySimpleSelectorSeqMatcher = new CheckedMatcher( |
122 | $matcherFactory->cssSimpleSelectorSeq(), |
123 | static function ( ComponentValueList $values, GrammarMatch $match, array $options ) { |
124 | foreach ( $match->getCapturedMatches() as $m ) { |
125 | if ( $m->getName() !== 'element' ) { |
126 | continue; |
127 | } |
128 | $str = (string)$m; |
129 | return $str === 'html' || $str === 'body'; |
130 | } |
131 | return false; |
132 | } |
133 | ); |
134 | |
135 | $prependSelectors = [ |
136 | new Token( Token::T_DELIM, '.' ), |
137 | new Token( Token::T_IDENT, $class ), |
138 | ]; |
139 | if ( $extraWrapper !== null ) { |
140 | $extraComponentValues = self::validateExtraWrapper( $extraWrapper ); |
141 | if ( !$extraComponentValues ) { |
142 | throw new InvalidArgumentException( "Invalid value for \$extraWrapper: $extraWrapper" ); |
143 | } |
144 | $prependSelectors = array_merge( |
145 | $prependSelectors, |
146 | [ new Token( Token::T_WHITESPACE, [ 'significant' => true ] ) ], |
147 | $extraComponentValues |
148 | ); |
149 | } |
150 | |
151 | $disallowedAtRules = $config->get( 'TemplateStylesDisallowedAtRules' ); |
152 | |
153 | $ruleSanitizers = [ |
154 | 'styles' => new StyleRuleSanitizer( |
155 | $matcherFactory->cssSelectorList(), |
156 | $propertySanitizer, |
157 | [ |
158 | 'prependSelectors' => $prependSelectors, |
159 | 'hoistableComponentMatcher' => $htmlOrBodySimpleSelectorSeqMatcher, |
160 | ] |
161 | ), |
162 | '@font-face' => new TemplateStylesFontFaceAtRuleSanitizer( $matcherFactory ), |
163 | '@keyframes' => new KeyframesAtRuleSanitizer( $matcherFactory, $propertySanitizer ), |
164 | '@page' => new PageAtRuleSanitizer( $matcherFactory, $propertySanitizer ), |
165 | '@media' => new MediaAtRuleSanitizer( $matcherFactory->cssMediaQueryList() ), |
166 | '@supports' => new SupportsAtRuleSanitizer( $matcherFactory, [ |
167 | 'declarationSanitizer' => $propertySanitizer, |
168 | ] ), |
169 | ]; |
170 | $ruleSanitizers = array_diff_key( $ruleSanitizers, array_flip( $disallowedAtRules ) ); |
171 | if ( isset( $ruleSanitizers['@media'] ) ) { |
172 | // In case @media was disallowed |
173 | $ruleSanitizers['@media']->setRuleSanitizers( $ruleSanitizers ); |
174 | } |
175 | if ( isset( $ruleSanitizers['@supports'] ) ) { |
176 | // In case @supports was disallowed |
177 | $ruleSanitizers['@supports']->setRuleSanitizers( $ruleSanitizers ); |
178 | } |
179 | |
180 | $allRuleSanitizers = $ruleSanitizers + [ |
181 | // Omit @import, it's not secure. Maybe someday we'll make an "@-mw-import" or something. |
182 | '@namespace' => new NamespaceAtRuleSanitizer( $matcherFactory ), |
183 | ]; |
184 | $allRuleSanitizers = array_diff_key( $allRuleSanitizers, $disallowedAtRules ); |
185 | $sanitizer = new StylesheetSanitizer( $allRuleSanitizers ); |
186 | $hookRunner->onTemplateStylesStylesheetSanitizer( |
187 | $sanitizer, $propertySanitizer, $matcherFactory |
188 | ); |
189 | self::$sanitizers[$key] = $sanitizer; |
190 | } |
191 | return self::$sanitizers[$key]; |
192 | } |
193 | |
194 | /** |
195 | * Update $wgTextModelsToParse |
196 | */ |
197 | public static function onRegistration() { |
198 | // This gets called before ConfigFactory is set up, so I guess we need |
199 | // to use globals. |
200 | global $wgTextModelsToParse, $wgTemplateStylesAutoParseContent; |
201 | |
202 | if ( in_array( CONTENT_MODEL_CSS, $wgTextModelsToParse, true ) && |
203 | $wgTemplateStylesAutoParseContent |
204 | ) { |
205 | $wgTextModelsToParse[] = 'sanitized-css'; |
206 | } |
207 | } |
208 | |
209 | /** |
210 | * Add `<templatestyles>` to the parser. |
211 | * @param Parser $parser Parser object being cleared |
212 | */ |
213 | public function onParserFirstCallInit( $parser ) { |
214 | $parser->setHook( 'templatestyles', [ __CLASS__, 'handleTag' ] ); |
215 | // 100 is arbitrary |
216 | $parser->extTemplateStylesCache = new MapCacheLRU( 100 ); |
217 | } |
218 | |
219 | /** |
220 | * Set the default content model to 'sanitized-css' when appropriate. |
221 | * @param Title $title the Title in question |
222 | * @param string &$model The model name |
223 | * @return bool |
224 | */ |
225 | public function onContentHandlerDefaultModelFor( $title, &$model ) { |
226 | // Allow overwriting attributes with config settings. |
227 | // Attributes can not use namespaces as keys, as processing them does not preserve |
228 | // integer keys. |
229 | $enabledNamespaces = self::getConfig()->get( 'TemplateStylesNamespaces' ) + |
230 | array_fill_keys( |
231 | ExtensionRegistry::getInstance()->getAttribute( 'TemplateStylesNamespaces' ), |
232 | true |
233 | ); |
234 | |
235 | if ( !empty( $enabledNamespaces[$title->getNamespace()] ) && |
236 | $title->isSubpage() && substr( $title->getText(), -4 ) === '.css' |
237 | ) { |
238 | $model = 'sanitized-css'; |
239 | return false; |
240 | } |
241 | return true; |
242 | } |
243 | |
244 | /** |
245 | * Clear our cache when the parser is reset |
246 | * @param Parser $parser |
247 | */ |
248 | public function onParserClearState( $parser ) { |
249 | $parser->extTemplateStylesCache->clear(); |
250 | } |
251 | |
252 | /** |
253 | * Parser hook for `<templatestyles>` |
254 | * @param string $text Contents of the tag (ignored). |
255 | * @param string[] $params Tag attributes |
256 | * @param Parser $parser |
257 | * @param PPFrame $frame |
258 | * @return string HTML |
259 | * @suppress SecurityCheck-XSS |
260 | */ |
261 | public static function handleTag( $text, $params, $parser, $frame ) { |
262 | $config = self::getConfig(); |
263 | if ( $config->get( 'TemplateStylesDisable' ) ) { |
264 | return ''; |
265 | } |
266 | |
267 | if ( !isset( $params['src'] ) || trim( $params['src'] ) === '' ) { |
268 | return self::formatTagError( $parser, [ 'templatestyles-missing-src' ] ); |
269 | } |
270 | |
271 | $extraWrapper = null; |
272 | if ( isset( $params['wrapper'] ) && trim( $params['wrapper'] ) !== '' ) { |
273 | $extraWrapper = trim( $params['wrapper'] ); |
274 | if ( !self::validateExtraWrapper( $extraWrapper ) ) { |
275 | return self::formatTagError( $parser, [ 'templatestyles-invalid-wrapper' ] ); |
276 | } |
277 | } |
278 | |
279 | // Default to the Template namespace because that's the most likely |
280 | // situation. We can't allow for subpage syntax like src="/styles.css" |
281 | // or the like, though, because stuff like substing and Parsoid would |
282 | // wind up wanting to make that relative to the wrong page. |
283 | $title = Title::newFromText( $params['src'], $config->get( 'TemplateStylesDefaultNamespace' ) ); |
284 | if ( !$title || $title->isExternal() ) { |
285 | return self::formatTagError( $parser, [ 'templatestyles-invalid-src' ] ); |
286 | } |
287 | |
288 | $revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title ); |
289 | |
290 | // It's not really a "template", but it has the same implications |
291 | // for needing reparse when the stylesheet is edited. |
292 | $parser->getOutput()->addTemplate( |
293 | $title, |
294 | $title->getArticleId(), |
295 | $revRecord ? $revRecord->getId() : null |
296 | ); |
297 | |
298 | $content = $revRecord ? $revRecord->getContent( SlotRecord::MAIN ) : null; |
299 | if ( !$content ) { |
300 | $titleText = $title->getPrefixedText(); |
301 | return self::formatTagError( $parser, [ |
302 | 'templatestyles-bad-src-missing', |
303 | $titleText, |
304 | wfEscapeWikiText( $titleText ) |
305 | ] ); |
306 | } |
307 | if ( !$content instanceof TemplateStylesContent ) { |
308 | $titleText = $title->getPrefixedText(); |
309 | return self::formatTagError( $parser, [ |
310 | 'templatestyles-bad-src', |
311 | $titleText, |
312 | wfEscapeWikiText( $titleText ), |
313 | ContentHandler::getLocalizedName( $content->getModel() ) |
314 | ] ); |
315 | } |
316 | |
317 | // If the revision actually has an ID, cache based on that. |
318 | // Otherwise, cache by hash. |
319 | if ( $revRecord->getId() ) { |
320 | $cacheKey = 'r' . $revRecord->getId(); |
321 | } else { |
322 | $cacheKey = sha1( $content->getText() ); |
323 | } |
324 | |
325 | // Include any non-default wrapper class in the cache key too |
326 | $wrapClass = $parser->getOptions()->getWrapOutputClass(); |
327 | if ( $wrapClass === false ) { |
328 | // deprecated |
329 | $wrapClass = 'mw-parser-output'; |
330 | } |
331 | if ( $wrapClass !== 'mw-parser-output' || $extraWrapper !== null ) { |
332 | $cacheKey .= '/' . $wrapClass; |
333 | if ( $extraWrapper !== null ) { |
334 | $cacheKey .= '/' . $extraWrapper; |
335 | } |
336 | } |
337 | |
338 | // Already cached? |
339 | if ( $parser->extTemplateStylesCache->has( $cacheKey ) ) { |
340 | return $parser->extTemplateStylesCache->get( $cacheKey ); |
341 | } |
342 | |
343 | $targetDir = $parser->getTargetLanguage()->getDir(); |
344 | $contentDir = $parser->getContentLanguage()->getDir(); |
345 | |
346 | $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory(); |
347 | $contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() ); |
348 | '@phan-var TemplateStylesContentHandler $contentHandler'; |
349 | $status = $contentHandler->sanitize( |
350 | $content, |
351 | [ |
352 | 'flip' => $targetDir !== $contentDir, |
353 | 'minify' => true, |
354 | 'class' => $wrapClass, |
355 | 'extraWrapper' => $extraWrapper, |
356 | ] |
357 | ); |
358 | $style = $status->isOk() ? $status->getValue() : '/* Fatal error, no CSS will be output */'; |
359 | |
360 | // Prepend errors. This should normally never happen, but might if an |
361 | // update or configuration change causes something that was formerly |
362 | // valid to become invalid or something like that. |
363 | if ( !$status->isGood() ) { |
364 | $comment = wfMessage( |
365 | 'templatestyles-errorcomment', |
366 | $title->getPrefixedText(), |
367 | $revRecord->getId(), |
368 | $status->getWikiText( false, 'rawmessage' ) |
369 | )->text(); |
370 | $comment = trim( strtr( $comment, [ |
371 | // Use some lookalike unicode characters to avoid things that might |
372 | // otherwise confuse browsers. |
373 | '*' => '•', '-' => '‐', '<' => '⧼', '>' => '⧽', |
374 | ] ) ); |
375 | $style = "/*\n$comment\n*/\n$style"; |
376 | } |
377 | |
378 | // Hide the CSS from Parser::doBlockLevels |
379 | $marker = Parser::MARKER_PREFIX . '-templatestyles-' . |
380 | sprintf( '%08X', $parser->mMarkerIndex++ ) . Parser::MARKER_SUFFIX; |
381 | $parser->getStripState()->addNoWiki( $marker, $style ); |
382 | |
383 | // Return the inline <style>, which the Parser will wrap in a 'general' |
384 | // strip marker. |
385 | $ret = Html::inlineStyle( $marker, 'all', [ |
386 | 'data-mw-deduplicate' => "TemplateStyles:$cacheKey", |
387 | ] ); |
388 | $parser->extTemplateStylesCache->set( $cacheKey, $ret ); |
389 | return $ret; |
390 | } |
391 | |
392 | /** |
393 | * Format an error in the `<templatestyles>` tag |
394 | * @param Parser $parser |
395 | * @param array $msg Arguments to wfMessage() |
396 | * @phan-param non-empty-array $msg |
397 | * @return string HTML |
398 | */ |
399 | private static function formatTagError( Parser $parser, array $msg ) { |
400 | $parser->addTrackingCategory( 'templatestyles-page-error-category' ); |
401 | return '<strong class="error">' . |
402 | wfMessage( ...$msg )->inContentLanguage()->parse() . |
403 | '</strong>'; |
404 | } |
405 | |
406 | } |