<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * SVG Sanitizer.
 *
 * A class that is responsible for sanitizing SVG files.
 *
 */
class BwgSvg_Sanitizer {

	/**
	 * @var \DOMDocument
	 */
	private $svg_dom = null;

	/**
	 * Sanitize File
	 *
	 * @since 3.16.0
	 * @access public
	 *
	 * @param $filename
	 * @return bool
	 */
	public function sanitize_file( $filename ) {
		$original_content = $this->file_get_contents( $filename );
		$is_encoded = $this->is_encoded( $original_content );

		if ( $is_encoded ) {
			$decoded = $this->decode_svg( $original_content );
			if ( false === $decoded ) {
				return false;
			}
			$original_content = $decoded;
		}

		$valid_svg = $this->sanitize( $original_content );
		if ( false === $valid_svg ) {
			return false;
		}

		// If we were gzipped, we need to re-zip
		if ( $is_encoded ) {
			$valid_svg = $this->encode_svg( $valid_svg );
		}
		file_put_contents( $filename, $valid_svg );

		return true;
	}

    /**
     * @param $file
     * @param mixed ...$args
     * @return false|string
     */
    public function file_get_contents( $file, ...$args ) {
        if ( realpath( $file ) === false || ! is_file( $file ) || ! is_readable( $file ) ) {
            return false;
        }

        return file_get_contents( $file, ...$args );
    }

	/**
	 * Sanitize
	 *
	 * @since 3.16.0
	 * @access public
	 *
	 * @param $content
	 * @return bool|string
	 */
	public function sanitize( $content ) {
		// Strip php tags
		$content = $this->strip_comments( $content );
		$content = $this->strip_php_tags( $content );
		$content = $this->strip_line_breaks( $content );

		// Find the start and end tags so we can cut out miscellaneous garbage.
		$start = strpos( $content, '<svg' );
		$end = strrpos( $content, '</svg>' );
		if ( false === $start || false === $end ) {
			return false;
		}

		$content = substr( $content, $start, ( $end - $start + 6 ) );

		// If the server's PHP version is 8 or up, make sure to Disable the ability to load external entities
		$php_version_under_eight = version_compare( PHP_VERSION, '8.0.0', '<' );
		if ( $php_version_under_eight ) {
			$libxml_disable_entity_loader = libxml_disable_entity_loader( true ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
		}
		// Suppress the errors
		$libxml_use_internal_errors = libxml_use_internal_errors( true );

		// Create DomDocument instance
		$this->svg_dom = new \DOMDocument();
		$this->svg_dom->formatOutput = false;
		$this->svg_dom->preserveWhiteSpace = false;
		$this->svg_dom->strictErrorChecking = false;

		$open_svg = $this->svg_dom->loadXML( $content );
		if ( ! $open_svg ) {
			return false;
		}

		$this->strip_doctype();
		$this->sanitize_elements();

		// Export sanitized svg to string
		// Using documentElement to strip out <?xml version="1.0" encoding="UTF-8"...
		$sanitized = $this->svg_dom->saveXML( $this->svg_dom->documentElement, LIBXML_NOEMPTYTAG );

		// Restore defaults
		if ( $php_version_under_eight ) {
			libxml_disable_entity_loader( $libxml_disable_entity_loader ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
		}
		libxml_use_internal_errors( $libxml_use_internal_errors );

		return $sanitized;
	}

	/**
	 * Is Encoded
	 *
	 * Check if the contents of the SVG file are gzipped
	 * @see http://www.gzip.org/zlib/rfc-gzip.html#member-format
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $contents
	 *
	 * @return bool
	 */
	private function is_encoded( $contents ) {
		$needle = "\x1f\x8b\x08";
		if ( function_exists( 'mb_strpos' ) ) {
			return 0 === mb_strpos( $contents, $needle );
		} else {
			return 0 === strpos( $contents, $needle );
		}
	}

	/**
	 * Encode SVG
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $content
	 * @return string
	 */
	private function encode_svg( $content ) {
		return gzencode( $content );
	}

	/**
	 * Decode SVG
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $content
	 *
	 * @return string
	 */
	private function decode_svg( $content ) {
		return gzdecode( $content );
	}

	/**
	 * Is Allowed Tag
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $element
	 * @return bool
	 */
	private function is_allowed_tag( $element ) {
		static $allowed_tags = false;
		if ( false === $allowed_tags ) {
			$allowed_tags = $this->get_allowed_elements();
		}

		$tag_name = $element->tagName; // phpcs:ignore -- php DomDocument
		if ( ! in_array( strtolower( $tag_name ), $allowed_tags ) ) {
			$this->remove_element( $element );
			return false;
		}

		return true;
	}

	/**
	 * Remove Element
	 *
	 * Removes the passed element from its DomDocument tree
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $element
	 */
	private function remove_element( $element ) {
		$element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomDocument
	}

	/**
	 * Is It An Attribute
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $name
	 * @param $check
	 * @return bool
	 */
	private function is_a_attribute( $name, $check ) {
		return 0 === strpos( $name, $check . '-' );
	}

	/**
	 * Is Remote Value
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $value
	 * @return string
	 */
	private function is_remote_value( $value ) {
		$value = trim( preg_replace( '/[^ -~]/xu', '', $value ) );
		$wrapped_in_url = preg_match( '~^url\(\s*[\'"]\s*(.*)\s*[\'"]\s*\)$~xi', $value, $match );
		if ( ! $wrapped_in_url ) {
			return false;
		}

		$value = trim( $match[1], '\'"' );
		return preg_match( '~^((https?|ftp|file):)?//~xi', $value );
	}

	/**
	 * Has JS Value
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $value
	 * @return false|int
	 */
	private function has_js_value( $value ) {
		// Normalize: collapse/remove whitespace so "javascr\u0009ipt" cannot bypass.
		$normalized = preg_replace( '/\s+/u', '', $value );
		return preg_match( '/base64|data|(?:java)?script|alert\(|window\.|document/i', $normalized );
	}

	/**
	 * Get Allowed Attributes
	 *
	 * Returns an array of allowed tag attributes in SVG files.
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @return array
	 */
	private function get_allowed_attributes() {
		$allowed_attributes = [
			'class',
			'clip-path',
			'clip-rule',
			'fill',
			'fill-opacity',
			'fill-rule',
			'filter',
			'id',
			'mask',
			'opacity',
			'stroke',
			'stroke-dasharray',
			'stroke-dashoffset',
			'stroke-linecap',
			'stroke-linejoin',
			'stroke-miterlimit',
			'stroke-opacity',
			'stroke-width',
			'style',
			'systemlanguage',
			'transform',
			'href',
			'xlink:href',
			'xlink:title',
			'cx',
			'cy',
			'r',
			'requiredfeatures',
			'clippathunits',
			'type',
			'rx',
			'ry',
			'color-interpolation-filters',
			'stddeviation',
			'filterres',
			'filterunits',
			'height',
			'primitiveunits',
			'width',
			'x',
			'y',
			'font-size',
			'display',
			'font-family',
			'font-style',
			'font-weight',
			'text-anchor',
			'marker-end',
			'marker-mid',
			'marker-start',
			'x1',
			'x2',
			'y1',
			'y2',
			'gradienttransform',
			'gradientunits',
			'spreadmethod',
			'markerheight',
			'markerunits',
			'markerwidth',
			'orient',
			'preserveaspectratio',
			'refx',
			'refy',
			'viewbox',
			'maskcontentunits',
			'maskunits',
			'd',
			'patterncontentunits',
			'patterntransform',
			'patternunits',
			'points',
			'fx',
			'fy',
			'offset',
			'stop-color',
			'stop-opacity',
			'xmlns',
			'xmlns:se',
			'xmlns:xlink',
			'xml:space',
			'method',
			'spacing',
			'startoffset',
			'dx',
			'dy',
			'rotate',
			'textlength',
		];
		return $allowed_attributes;
	}

	/**
	 * Get Allowed Elements
	 *
	 * Returns an array of allowed element tags to be in SVG files.
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @return array
	 */
	private function get_allowed_elements() {
		$allowed_elements = [
			'a',
			'circle',
			'clippath',
			'defs',
			'style',
			'desc',
			'ellipse',
			'fegaussianblur',
			'filter',
			'foreignobject',
			'g',
			'line',
			'lineargradient',
			'marker',
			'mask',
			'metadata',
			'path',
			'pattern',
			'polygon',
			'polyline',
			'radialgradient',
			'rect',
			'stop',
			'svg',
			'switch',
			'symbol',
			'text',
			'textpath',
			'title',
			'tspan',
			'use',
		];

		return $allowed_elements;
	}

	/**
	 * Validate Allowed Attributes
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param \DOMElement $element
	 */
	private function validate_allowed_attributes( $element ) {
		static $allowed_attributes = false;
		if ( false === $allowed_attributes ) {
			$allowed_attributes = $this->get_allowed_attributes();
		}

		for ( $index = $element->attributes->length - 1; $index >= 0; $index-- ) {
			// get attribute name
			$attr_name = $element->attributes->item( $index )->name;
			$attr_name_lowercase = strtolower( $attr_name );
			// Remove attribute if not in whitelist
			if ( ! in_array( $attr_name_lowercase, $allowed_attributes ) && ! $this->is_a_attribute( $attr_name_lowercase, 'aria' ) && ! $this->is_a_attribute( $attr_name_lowercase, 'data' ) ) {
				$element->removeAttribute( $attr_name );
				continue;
			}

			$attr_value = $element->attributes->item( $index )->value;

			// Remove attribute if it has a remote reference or js or data-URI/base64
			if ( ! empty( $attr_value ) && ( $this->is_remote_value( $attr_value ) || $this->has_js_value( $attr_value ) ) ) {
				$element->removeAttribute( $attr_name );
				continue;
			}
		}
	}

	/**
	 * Strip xlinks
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param \DOMElement $element
	 */
	private function strip_xlinks( $element ) {
		$xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );

		if ( $xlinks && ! $this->is_safe_href( $xlinks ) ) {
			$element->removeAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
		}

		// Also validate plain href (e.g. on <a>) — same rules as xlink:href.
		$href = $element->getAttribute( 'href' );
		if ( $href !== '' && ! $this->is_safe_href( $href ) ) {
			$element->removeAttribute( 'href' );
		}
	}

	/**
	 * @see https://github.com/darylldoyle/svg-sanitizer/blob/2321a914e/src/Sanitizer.php#L454
	 */
	private function is_safe_href( $value ) {
		// Allow empty values.
		if ( empty( $value ) ) {
			return true;
		}

		// Reject script schemes (e.g. javascript:, vbscript:). Normalize whitespace to catch "javascr ipt:".
		$value_normalized = preg_replace( '/\s+/u', '', $value );
		if ( preg_match( '/^(javascript|vbscript):/i', $value_normalized ) ) {
			return false;
		}

		// Allow fragment identifiers.
		if ( '#' === substr( $value, 0, 1 ) ) {
			return true;
		}

		// Allow relative URIs.
		if ( '/' === substr( $value, 0, 1 ) ) {
			return true;
		}

		// Allow HTTPS domains.
		if ( 'https://' === substr( $value, 0, 8 ) ) {
			return true;
		}

		// Allow HTTP domains.
		if ( 'http://' === substr( $value, 0, 7 ) ) {
			return true;
		}

		// Allow known data URIs.
		if ( in_array( substr( $value, 0, 14 ), [
			'data:image/png', // PNG
			'data:image/gif', // GIF
			'data:image/jpg', // JPG
			'data:image/jpe', // JPEG
			'data:image/pjp', // PJPEG
		], true ) ) {
			return true;
		}

		// Allow known short data URIs.
		if ( in_array( substr( $value, 0, 12 ), [
			'data:img/png', // PNG
			'data:img/gif', // GIF
			'data:img/jpg', // JPG
			'data:img/jpe', // JPEG
			'data:img/pjp', // PJPEG
		], true ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Validate Use Tag
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $element
	 */
	private function validate_use_tag( $element ) {
		$xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
		if ( $xlinks && '#' !== substr( $xlinks, 0, 1 ) ) {
			$element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomNode
		}
	}

	/**
	 * Strip Doctype
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 */
	private function strip_doctype() {
		foreach ( $this->svg_dom->childNodes as $child ) {
			if ( XML_DOCUMENT_TYPE_NODE === $child->nodeType ) { // phpcs:ignore -- php DomDocument
				$child->parentNode->removeChild( $child ); // phpcs:ignore -- php DomDocument
			}
		}
	}

	/**
	 * Sanitize Elements
	 *
	 * @since 3.16.0
	 * @access private
	 */
	private function sanitize_elements() {
		$elements = $this->svg_dom->getElementsByTagName( '*' );
		// loop through all elements
		// we do this backwards so we don't skip anything if we delete a node
		// see comments at: http://php.net/manual/en/class.domnamednodemap.php
		for ( $index = $elements->length - 1; $index >= 0; $index-- ) {
			/**
			 * @var \DOMElement $current_element
			 */
			$current_element = $elements->item( $index );
			// If the tag isn't in the whitelist, remove it and continue with next iteration
			if ( ! $this->is_allowed_tag( $current_element ) ) {
				continue;
			}

			//validate element attributes
			$this->validate_allowed_attributes( $current_element );

			$this->strip_xlinks( $current_element );

			if ( 'use' === strtolower( $current_element->tagName ) ) { // phpcs:ignore -- php DomDocument
				$this->validate_use_tag( $current_element );
			}
		}
	}

	/**
	 * Strip PHP Tags
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $string
	 * @return string
	 */
	private function strip_php_tags( $string ) {
		$string = preg_replace( '/<\?(=|php)(.+?)\?>/i', '', $string );
		// Remove XML, ASP, etc.
		$string = preg_replace( '/<\?(.*)\?>/Us', '', $string );
		$string = preg_replace( '/<\%(.*)\%>/Us', '', $string );

		if ( ( false !== strpos( $string, '<?' ) ) || ( false !== strpos( $string, '<%' ) ) ) {
			return '';
		}
		return $string;
	}

	/**
	 * Strip Comments
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $string
	 * @return string
	 */
	private function strip_comments( $string ) {
		// Remove comments.
		$string = preg_replace( '/<!--(.*)-->/Us', '', $string );
		$string = preg_replace( '/\/\*(.*)\*\//Us', '', $string );
		if ( ( false !== strpos( $string, '<!--' ) ) || ( false !== strpos( $string, '/*' ) ) ) {
			return '';
		}
		return $string;
	}

	/**
	 * Strip Line Breaks
	 *
	 * @since 3.16.0
	 * @access private
	 *
	 * @param $string
	 * @return string
	 */
	private function strip_line_breaks( $string ) {
		// Remove line breaks.
		return preg_replace( '/\r|\n/', '', $string );
	}
}
