]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - vendor/oojs/oojs-ui/php/Tag.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / vendor / oojs / oojs-ui / php / Tag.php
1 <?php
2
3 namespace OOUI;
4
5 class Tag {
6
7         /* Properties */
8
9         /**
10          * Tag name for this instance.
11          *
12          * @var string HTML tag name
13          */
14         protected $tag = '';
15
16         /**
17          * Attributes.
18          *
19          * @var array HTML attributes
20          */
21         protected $attributes = [];
22
23         /**
24          * Classes.
25          *
26          * @var array CSS classes
27          */
28         protected $classes = [];
29
30         /**
31          * Content.
32          *
33          * @var array Content text and elements
34          */
35         protected $content = [];
36
37         /**
38          * Group.
39          *
40          * @var GroupElement|null Group element is in
41          */
42         protected $elementGroup = null;
43
44         /**
45          * Infusion support.
46          *
47          * @var boolean Whether to serialize tag/element/widget state for client-side use.
48          */
49         protected $infusable = false;
50
51         /* Methods */
52
53         /**
54          * Create element.
55          *
56          * @param string $tag HTML tag name
57          */
58         public function __construct( $tag = 'div' ) {
59                 $this->tag = $tag;
60         }
61
62         /**
63          * Check for CSS class.
64          *
65          * @param string $class CSS class name
66          * @return bool
67          */
68         public function hasClass( $class ) {
69                 return in_array( $class, $this->classes );
70         }
71
72         /**
73          * Add CSS classes.
74          *
75          * @param array $classes List of classes to add
76          * @return $this
77          */
78         public function addClasses( array $classes ) {
79                 $this->classes = array_merge( $this->classes, $classes );
80                 return $this;
81         }
82
83         /**
84          * Remove CSS classes.
85          *
86          * @param array $classes List of classes to remove
87          * @return $this
88          */
89         public function removeClasses( array $classes ) {
90                 $this->classes = array_diff( $this->classes, $classes );
91                 return $this;
92         }
93
94         /**
95          * Toggle CSS classes.
96          *
97          * @param array $classes List of classes to add
98          * @param bool $toggle Add classes
99          * @return $this
100          */
101         public function toggleClasses( array $classes, $toggle = null ) {
102                 if ( $toggle === null ) {
103                         $this->classes = array_diff(
104                                 array_merge( $this->classes, $classes ),
105                                 array_intersect( $this->classes, $classes )
106                         );
107                 } elseif ( $toggle ) {
108                         $this->classes = array_merge( $this->classes, $classes );
109                 } else {
110                         $this->classes = array_diff( $this->classes, $classes );
111                 }
112                 return $this;
113         }
114
115         public function getTag() {
116                 return $this->tag;
117         }
118
119         /**
120          * Get HTML attribute value.
121          *
122          * @param string $key HTML attribute name
123          * @return string|null
124          */
125         public function getAttribute( $key ) {
126                 return isset( $this->attributes[$key] ) ? $this->attributes[$key] : null;
127         }
128
129         /**
130          * Add HTML attributes.
131          *
132          * @param array $attributes List of attribute key/value pairs to add
133          * @return $this
134          */
135         public function setAttributes( array $attributes ) {
136                 foreach ( $attributes as $key => $value ) {
137                         $this->attributes[$key] = $value;
138                 }
139                 return $this;
140         }
141
142         /**
143          * Set value of input element ('value' attribute for most, element content for textarea).
144          *
145          * @param string $value Value to set
146          * @return $this
147          */
148         public function setValue( $value ) {
149                 if ( strtolower( $this->tag ) === 'textarea' ) {
150                         $this->clearContent();
151                         $this->appendContent( $value );
152                 } else {
153                         $this->setAttributes( [ 'value' => $value ] );
154                 }
155                 return $this;
156         }
157
158         /**
159          * Remove HTML attributes.
160          *
161          * @param array $keys List of attribute keys to remove
162          * @return $this
163          */
164         public function removeAttributes( array $keys ) {
165                 foreach ( $keys as $key ) {
166                         unset( $this->attributes[$key] );
167                 }
168                 return $this;
169         }
170
171         /**
172          * Add content to the end.
173          *
174          * Accepts either variadic arguments (the $content argument can be repeated any number of times)
175          * or an array of arguments.
176          *
177          * For example, these uses are valid:
178          * * $tag->appendContent( [ $element1, $element2 ] );
179          * * $tag->appendContent( $element1, $element2 );
180          * This, however, is not acceptable
181          * * $tag->appendContent( [ $element1, $element2 ], $element3 );
182          *
183          * @param string|Tag|HtmlSnippet $content Content to append. Strings will be HTML-escaped
184          *   for output, use a HtmlSnippet instance to prevent that.
185          * @return $this
186          */
187         public function appendContent( /* $content... */ ) {
188                 $contents = func_get_args();
189                 if ( is_array( $contents[ 0 ] ) ) {
190                         $this->content = array_merge( $this->content, $contents[ 0 ] );
191                 } else {
192                         $this->content = array_merge( $this->content, $contents );
193                 }
194                 return $this;
195         }
196
197         /**
198          * Add content to the beginning.
199          *
200          * Accepts either variadic arguments (the $content argument can be repeated any number of times)
201          * or an array of arguments.
202          *
203          * For example, these uses are valid:
204          * * $tag->prependContent( [ $element1, $element2 ] );
205          * * $tag->prependContent( $element1, $element2 );
206          * This, however, is not acceptable
207          * * $tag->prependContent( [ $element1, $element2 ], $element3 );
208          *
209          * @param string|Tag|HtmlSnippet $content Content to prepend. Strings will be HTML-escaped
210          *   for output, use a HtmlSnippet instance to prevent that.
211          * @return $this
212          */
213         public function prependContent( /* $content... */ ) {
214                 $contents = func_get_args();
215                 if ( is_array( $contents[ 0 ] ) ) {
216                         array_splice( $this->content, 0, 0, $contents[ 0 ] );
217                 } else {
218                         array_splice( $this->content, 0, 0, $contents );
219                 }
220                 return $this;
221         }
222
223         /**
224          * Remove all content.
225          *
226          * @return $this
227          */
228         public function clearContent() {
229                 $this->content = [];
230                 return $this;
231         }
232
233         /**
234          * Get group element is in.
235          *
236          * @return GroupElement|null Group element, null if none
237          */
238         public function getElementGroup() {
239                 return $this->elementGroup;
240         }
241
242         /**
243          * Set group element is in.
244          *
245          * @param GroupElement|null $group Group element, null if none
246          * @return $this
247          */
248         public function setElementGroup( $group ) {
249                 $this->elementGroup = $group;
250                 return $this;
251         }
252
253         /**
254          * Enable widget for client-side infusion.
255          *
256          * @param bool $infusable True to allow tag/element/widget to be referenced client-side.
257          * @return $this
258          */
259         public function setInfusable( $infusable ) {
260                 $this->infusable = $infusable;
261                 return $this;
262         }
263
264         /**
265          * Get client-side infusability.
266          *
267          * @return bool If this tag/element/widget can be referenced client-side.
268          */
269         public function isInfusable() {
270                 return $this->infusable;
271         }
272
273         private static $elementId = 0;
274
275         /**
276          * Generate a unique ID for element
277          *
278          * @return string ID
279          */
280         public static function generateElementId() {
281                 self::$elementId++;
282                 return 'ooui-' . self::$elementId;
283         }
284
285         /**
286          * Ensure that this given Tag is infusable and has a unique `id`
287          * attribute.
288          * @return $this
289          */
290         public function ensureInfusableId() {
291                 $this->setInfusable( true );
292                 if ( $this->getAttribute( 'id' ) === null ) {
293                         $this->setAttributes( [ 'id' => self::generateElementId() ] );
294                 }
295                 return $this;
296         }
297
298         /**
299          * Return an augmented `attributes` array, including synthetic attributes
300          * which are created from other properties (like the `classes` array)
301          * but which shouldn't be retained in the user-visible `attributes`.
302          * @return array An attributes array.
303          */
304         protected function getGeneratedAttributes() {
305                 // Copy attributes, add `class` attribute from `$this->classes` array.
306                 $attributesArray = $this->attributes;
307                 if ( $this->classes ) {
308                         $attributesArray['class'] = implode( ' ', array_unique( $this->classes ) );
309                 }
310                 if ( $this->infusable ) {
311                         // Indicate that this is "just" a tag (not a widget)
312                         $attributesArray['data-ooui'] = json_encode( [ '_' => 'Tag' ] );
313                 }
314                 return $attributesArray;
315         }
316
317         /**
318          * Check whether the string $haystack begins with the string $needle.
319          *
320          * @param string $haystack
321          * @param string $needle
322          * @return bool True if $haystack begins with $needle, false otherwise.
323          */
324         private static function stringStartsWith( $haystack, $needle ) {
325                 return strncmp( $haystack, $needle, strlen( $needle ) ) === 0;
326         }
327
328         /**
329          * Check whether user-supplied URL is safe, that is, whether outputting it will not result in XSS
330          * vulnerability. (Note that URLs must be HTML-escaped regardless of this check.)
331          *
332          * The point is to disallow 'javascript:' URLs (there are no good reasons to ever use them
333          * anyway), but there's no good way to blacklist them because of very lax parsing in browsers.
334          *
335          * An URL is safe if:
336          *
337          *  - it is empty, or
338          *  - it starts with a whitelisted protocol, followed by a colon (absolute URL), or
339          *  - it starts with two slashes `//` (protocol-relative URL), or
340          *  - it starts with a single slash `/`, or dot and slash `./` (relative URL), or
341          *  - it starts with a question mark `?` (replace query part in current URL), or
342          *  - it starts with a pound sign `#` (replace fragment part in current URL)
343          *
344          * Plain relative URLs (like `index.php`) are not allowed, since it's pretty much impossible to
345          * distinguish them from malformed absolute ones (again, very lax rules for parsing protocols).
346          *
347          * @param string $url URL
348          * @return bool [description]
349          */
350         public static function isSafeUrl( $url ) {
351                 // Keep this function in sync with OO.ui.isSafeUrl
352                 $protocolWhitelist = [
353                         // Sourced from MediaWiki's $wgUrlProtocols
354                         'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
355                         'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
356                         'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp',
357                 ];
358
359                 if ( $url === '' ) {
360                         return true;
361                 }
362
363                 foreach ( $protocolWhitelist as $protocol ) {
364                         if ( self::stringStartsWith( $url, $protocol . ':' ) ) {
365                                 return true;
366                         }
367                 }
368
369                 // This matches '//' too
370                 if ( self::stringStartsWith( $url, '/' ) || self::stringStartsWith( $url, './' ) ) {
371                         return true;
372                 }
373                 if ( self::stringStartsWith( $url, '?' ) || self::stringStartsWith( $url, '#' ) ) {
374                         return true;
375                 }
376
377                 return false;
378         }
379
380         /**
381          * Render element into HTML.
382          * @return string HTML serialization
383          * @throws Exception
384          */
385         public function toString() {
386                 // List of void elements from HTML5, section 8.1.2 as of 2016-09-19
387                 static $voidElements = [
388                         'area',
389                         'base',
390                         'br',
391                         'col',
392                         'embed',
393                         'hr',
394                         'img',
395                         'input',
396                         'keygen',
397                         'link',
398                         'meta',
399                         'param',
400                         'source',
401                         'track',
402                         'wbr',
403                 ];
404
405                 $attributes = '';
406                 foreach ( $this->getGeneratedAttributes() as $key => $value ) {
407                         if ( !preg_match( '/^[0-9a-zA-Z-]+$/', $key ) ) {
408                                 throw new Exception( 'Attribute name must consist of only ASCII letters, numbers and dash' );
409                         }
410
411                         // Note that this is not a complete list of HTML attributes that need this validation.
412                         // We only check for the ones that are generated by built-in OOjs UI PHP elements.
413                         if ( $key === 'href' || $key === 'action' ) {
414                                 if ( !self::isSafeUrl( $value ) ) {
415                                         // We can't tell for sure whether this URL is safe (although it might be). The only safe
416                                         // URLs that we can't check for is relative ones, so just prefix with './'. This should
417                                         // change nothing for relative URLs, and it will neutralize sneaky 'javascript:' URLs.
418                                         $value = './' . $value;
419                                 }
420                         }
421
422                         // Use single-quotes around the attribute value in HTML, because
423                         // some of the values might be JSON strings
424                         // 1. Encode both single and double quotes (and other special chars)
425                         $value = htmlspecialchars( $value, ENT_QUOTES );
426                         // 2. Decode double quotes, for readability.
427                         $value = str_replace( '&quot;', '"', $value );
428                         // 3. Wrap attribute value in single quotes in the HTML.
429                         $attributes .= ' ' . $key . "='" . $value . "'";
430                 }
431
432                 // Content
433                 $content = '';
434                 foreach ( $this->content as $part ) {
435                         if ( is_string( $part ) ) {
436                                 $content .= htmlspecialchars( $part );
437                         } elseif ( $part instanceof Tag || $part instanceof HtmlSnippet ) {
438                                 $content .= (string)$part;
439                         }
440                 }
441
442                 if ( !preg_match( '/^[0-9a-zA-Z]+$/', $this->tag ) ) {
443                         throw new Exception( 'Tag name must consist of only ASCII letters and numbers' );
444                 }
445
446                 // Tag
447                 if ( !$content && in_array( $this->tag, $voidElements ) ) {
448                         return '<' . $this->tag . $attributes . ' />';
449                 } else {
450                         return '<' . $this->tag . $attributes . '>' . $content . '</' . $this->tag . '>';
451                 }
452         }
453
454         /**
455          * Magic method implementation.
456          *
457          * PHP doesn't allow __toString to throw exceptions and will trigger a fatal error if it does.
458          * This is a wrapper around the real toString() to convert them to errors instead.
459          *
460          * @return string
461          */
462         public function __toString() {
463                 try {
464                         return $this->toString();
465                 } catch ( Exception $ex ) {
466                         trigger_error( (string)$ex, E_USER_ERROR );
467                         return '';
468                 }
469         }
470 }