X-Git-Url: https://scripts.mit.edu/gitweb/autoinstallsdev/mediawiki.git/blobdiff_plain/19e297c21b10b1b8a3acad5e73fc71dcb35db44a..6932310fd58ebef145fa01eb76edf7150284d8ea:/vendor/oojs/oojs-ui/php/Tag.php diff --git a/vendor/oojs/oojs-ui/php/Tag.php b/vendor/oojs/oojs-ui/php/Tag.php new file mode 100644 index 00000000..1c308bd3 --- /dev/null +++ b/vendor/oojs/oojs-ui/php/Tag.php @@ -0,0 +1,470 @@ +tag = $tag; + } + + /** + * Check for CSS class. + * + * @param string $class CSS class name + * @return bool + */ + public function hasClass( $class ) { + return in_array( $class, $this->classes ); + } + + /** + * Add CSS classes. + * + * @param array $classes List of classes to add + * @return $this + */ + public function addClasses( array $classes ) { + $this->classes = array_merge( $this->classes, $classes ); + return $this; + } + + /** + * Remove CSS classes. + * + * @param array $classes List of classes to remove + * @return $this + */ + public function removeClasses( array $classes ) { + $this->classes = array_diff( $this->classes, $classes ); + return $this; + } + + /** + * Toggle CSS classes. + * + * @param array $classes List of classes to add + * @param bool $toggle Add classes + * @return $this + */ + public function toggleClasses( array $classes, $toggle = null ) { + if ( $toggle === null ) { + $this->classes = array_diff( + array_merge( $this->classes, $classes ), + array_intersect( $this->classes, $classes ) + ); + } elseif ( $toggle ) { + $this->classes = array_merge( $this->classes, $classes ); + } else { + $this->classes = array_diff( $this->classes, $classes ); + } + return $this; + } + + public function getTag() { + return $this->tag; + } + + /** + * Get HTML attribute value. + * + * @param string $key HTML attribute name + * @return string|null + */ + public function getAttribute( $key ) { + return isset( $this->attributes[$key] ) ? $this->attributes[$key] : null; + } + + /** + * Add HTML attributes. + * + * @param array $attributes List of attribute key/value pairs to add + * @return $this + */ + public function setAttributes( array $attributes ) { + foreach ( $attributes as $key => $value ) { + $this->attributes[$key] = $value; + } + return $this; + } + + /** + * Set value of input element ('value' attribute for most, element content for textarea). + * + * @param string $value Value to set + * @return $this + */ + public function setValue( $value ) { + if ( strtolower( $this->tag ) === 'textarea' ) { + $this->clearContent(); + $this->appendContent( $value ); + } else { + $this->setAttributes( [ 'value' => $value ] ); + } + return $this; + } + + /** + * Remove HTML attributes. + * + * @param array $keys List of attribute keys to remove + * @return $this + */ + public function removeAttributes( array $keys ) { + foreach ( $keys as $key ) { + unset( $this->attributes[$key] ); + } + return $this; + } + + /** + * Add content to the end. + * + * Accepts either variadic arguments (the $content argument can be repeated any number of times) + * or an array of arguments. + * + * For example, these uses are valid: + * * $tag->appendContent( [ $element1, $element2 ] ); + * * $tag->appendContent( $element1, $element2 ); + * This, however, is not acceptable + * * $tag->appendContent( [ $element1, $element2 ], $element3 ); + * + * @param string|Tag|HtmlSnippet $content Content to append. Strings will be HTML-escaped + * for output, use a HtmlSnippet instance to prevent that. + * @return $this + */ + public function appendContent( /* $content... */ ) { + $contents = func_get_args(); + if ( is_array( $contents[ 0 ] ) ) { + $this->content = array_merge( $this->content, $contents[ 0 ] ); + } else { + $this->content = array_merge( $this->content, $contents ); + } + return $this; + } + + /** + * Add content to the beginning. + * + * Accepts either variadic arguments (the $content argument can be repeated any number of times) + * or an array of arguments. + * + * For example, these uses are valid: + * * $tag->prependContent( [ $element1, $element2 ] ); + * * $tag->prependContent( $element1, $element2 ); + * This, however, is not acceptable + * * $tag->prependContent( [ $element1, $element2 ], $element3 ); + * + * @param string|Tag|HtmlSnippet $content Content to prepend. Strings will be HTML-escaped + * for output, use a HtmlSnippet instance to prevent that. + * @return $this + */ + public function prependContent( /* $content... */ ) { + $contents = func_get_args(); + if ( is_array( $contents[ 0 ] ) ) { + array_splice( $this->content, 0, 0, $contents[ 0 ] ); + } else { + array_splice( $this->content, 0, 0, $contents ); + } + return $this; + } + + /** + * Remove all content. + * + * @return $this + */ + public function clearContent() { + $this->content = []; + return $this; + } + + /** + * Get group element is in. + * + * @return GroupElement|null Group element, null if none + */ + public function getElementGroup() { + return $this->elementGroup; + } + + /** + * Set group element is in. + * + * @param GroupElement|null $group Group element, null if none + * @return $this + */ + public function setElementGroup( $group ) { + $this->elementGroup = $group; + return $this; + } + + /** + * Enable widget for client-side infusion. + * + * @param bool $infusable True to allow tag/element/widget to be referenced client-side. + * @return $this + */ + public function setInfusable( $infusable ) { + $this->infusable = $infusable; + return $this; + } + + /** + * Get client-side infusability. + * + * @return bool If this tag/element/widget can be referenced client-side. + */ + public function isInfusable() { + return $this->infusable; + } + + private static $elementId = 0; + + /** + * Generate a unique ID for element + * + * @return string ID + */ + public static function generateElementId() { + self::$elementId++; + return 'ooui-' . self::$elementId; + } + + /** + * Ensure that this given Tag is infusable and has a unique `id` + * attribute. + * @return $this + */ + public function ensureInfusableId() { + $this->setInfusable( true ); + if ( $this->getAttribute( 'id' ) === null ) { + $this->setAttributes( [ 'id' => self::generateElementId() ] ); + } + return $this; + } + + /** + * Return an augmented `attributes` array, including synthetic attributes + * which are created from other properties (like the `classes` array) + * but which shouldn't be retained in the user-visible `attributes`. + * @return array An attributes array. + */ + protected function getGeneratedAttributes() { + // Copy attributes, add `class` attribute from `$this->classes` array. + $attributesArray = $this->attributes; + if ( $this->classes ) { + $attributesArray['class'] = implode( ' ', array_unique( $this->classes ) ); + } + if ( $this->infusable ) { + // Indicate that this is "just" a tag (not a widget) + $attributesArray['data-ooui'] = json_encode( [ '_' => 'Tag' ] ); + } + return $attributesArray; + } + + /** + * Check whether the string $haystack begins with the string $needle. + * + * @param string $haystack + * @param string $needle + * @return bool True if $haystack begins with $needle, false otherwise. + */ + private static function stringStartsWith( $haystack, $needle ) { + return strncmp( $haystack, $needle, strlen( $needle ) ) === 0; + } + + /** + * Check whether user-supplied URL is safe, that is, whether outputting it will not result in XSS + * vulnerability. (Note that URLs must be HTML-escaped regardless of this check.) + * + * The point is to disallow 'javascript:' URLs (there are no good reasons to ever use them + * anyway), but there's no good way to blacklist them because of very lax parsing in browsers. + * + * An URL is safe if: + * + * - it is empty, or + * - it starts with a whitelisted protocol, followed by a colon (absolute URL), or + * - it starts with two slashes `//` (protocol-relative URL), or + * - it starts with a single slash `/`, or dot and slash `./` (relative URL), or + * - it starts with a question mark `?` (replace query part in current URL), or + * - it starts with a pound sign `#` (replace fragment part in current URL) + * + * Plain relative URLs (like `index.php`) are not allowed, since it's pretty much impossible to + * distinguish them from malformed absolute ones (again, very lax rules for parsing protocols). + * + * @param string $url URL + * @return bool [description] + */ + public static function isSafeUrl( $url ) { + // Keep this function in sync with OO.ui.isSafeUrl + $protocolWhitelist = [ + // Sourced from MediaWiki's $wgUrlProtocols + 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs', + 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh', + 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp', + ]; + + if ( $url === '' ) { + return true; + } + + foreach ( $protocolWhitelist as $protocol ) { + if ( self::stringStartsWith( $url, $protocol . ':' ) ) { + return true; + } + } + + // This matches '//' too + if ( self::stringStartsWith( $url, '/' ) || self::stringStartsWith( $url, './' ) ) { + return true; + } + if ( self::stringStartsWith( $url, '?' ) || self::stringStartsWith( $url, '#' ) ) { + return true; + } + + return false; + } + + /** + * Render element into HTML. + * @return string HTML serialization + * @throws Exception + */ + public function toString() { + // List of void elements from HTML5, section 8.1.2 as of 2016-09-19 + static $voidElements = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + ]; + + $attributes = ''; + foreach ( $this->getGeneratedAttributes() as $key => $value ) { + if ( !preg_match( '/^[0-9a-zA-Z-]+$/', $key ) ) { + throw new Exception( 'Attribute name must consist of only ASCII letters, numbers and dash' ); + } + + // Note that this is not a complete list of HTML attributes that need this validation. + // We only check for the ones that are generated by built-in OOjs UI PHP elements. + if ( $key === 'href' || $key === 'action' ) { + if ( !self::isSafeUrl( $value ) ) { + // We can't tell for sure whether this URL is safe (although it might be). The only safe + // URLs that we can't check for is relative ones, so just prefix with './'. This should + // change nothing for relative URLs, and it will neutralize sneaky 'javascript:' URLs. + $value = './' . $value; + } + } + + // Use single-quotes around the attribute value in HTML, because + // some of the values might be JSON strings + // 1. Encode both single and double quotes (and other special chars) + $value = htmlspecialchars( $value, ENT_QUOTES ); + // 2. Decode double quotes, for readability. + $value = str_replace( '"', '"', $value ); + // 3. Wrap attribute value in single quotes in the HTML. + $attributes .= ' ' . $key . "='" . $value . "'"; + } + + // Content + $content = ''; + foreach ( $this->content as $part ) { + if ( is_string( $part ) ) { + $content .= htmlspecialchars( $part ); + } elseif ( $part instanceof Tag || $part instanceof HtmlSnippet ) { + $content .= (string)$part; + } + } + + if ( !preg_match( '/^[0-9a-zA-Z]+$/', $this->tag ) ) { + throw new Exception( 'Tag name must consist of only ASCII letters and numbers' ); + } + + // Tag + if ( !$content && in_array( $this->tag, $voidElements ) ) { + return '<' . $this->tag . $attributes . ' />'; + } else { + return '<' . $this->tag . $attributes . '>' . $content . 'tag . '>'; + } + } + + /** + * Magic method implementation. + * + * PHP doesn't allow __toString to throw exceptions and will trigger a fatal error if it does. + * This is a wrapper around the real toString() to convert them to errors instead. + * + * @return string + */ + public function __toString() { + try { + return $this->toString(); + } catch ( Exception $ex ) { + trigger_error( (string)$ex, E_USER_ERROR ); + return ''; + } + } +}