__( 'You do not have permission to upload SVG files.', 'admin-optimizer' ) ]; } $data = [ 'ext' => 'svg', 'type' => 'image/svg+xml', ]; if ( 'svgz' === $filetype['ext'] ) { $data['ext'] = 'svgz'; } } return $data; } /** * Check if the file is an SVG, if so handle appropriately * * @param array $file An array of data for a single file. * * @return array */ public function sanitize_svg_upload( array $file ) { if ( ! isset( $file['tmp_name'] ) ) { return $file; } $file_name = $file['name'] ?? ''; $file_type_ext = wp_check_filetype_and_ext( $file['tmp_name'], $file_name ); $file_type = ! empty( $file_type_ext['type'] ) ? $file_type_ext['type'] : ''; if ( 'image/svg+xml' === $file_type && ! $this->sanitize_svg_file( $file['tmp_name'] ) ) { $file['error'] = $this->sanitization_error_message(); } return $file; } /** * Capture data for WordPress's XML-RPC media method. * * XML-RPC uses wp_upload_bits(), which does not run the normal upload * prefilters. The data is retained only until wp_upload_bits is filtered. * * @param string $method XML-RPC method name after alias resolution. * @param array $args XML-RPC arguments. * @return void */ public function capture_xmlrpc_upload( $method, $args ) { $this->xmlrpc_upload_data = null; if ( 'metaWeblog.newMediaObject' === $method && isset( $args[3] ) && is_array( $args[3] ) ) { $this->xmlrpc_upload_data = $args[3]; } } /** * Sanitize XML-RPC SVG bytes before WordPress writes them to uploads. * * @param array|string $upload Upload bits or an existing error. * @return array|string */ public function sanitize_xmlrpc_svg_upload( $upload ) { $data = $this->xmlrpc_upload_data; $this->xmlrpc_upload_data = null; if ( ! is_array( $upload ) || ! is_array( $data ) ) { return $upload; } $filetype = wp_check_filetype( (string) ( $upload['name'] ?? '' ) ); if ( ! in_array( $filetype['ext'] ?? '', [ 'svg', 'svgz' ], true ) ) { return $upload; } $post_id = absint( $data['post_id'] ?? 0 ); if ( $post_id && ( ! get_post( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) ) { return __( 'Sorry, you are not allowed to attach this SVG to that post.', 'admin-optimizer' ); } $sanitized = $this->sanitize_svg_contents( $upload['bits'] ?? '', 'svgz' === ( $filetype['ext'] ?? '' ) ); if ( false === $sanitized ) { return $this->sanitization_error_message(); } $upload['bits'] = $sanitized; return $upload; } /** * Verify the persisted file before WordPress creates an attachment record. * * This catches filesystem write/truncation failures in both wp_upload_bits() * and the normal upload handler. Only the current file, validated as being * inside WordPress's uploads directory, is eligible for cleanup. * * @param array $upload WordPress upload result. * @return array */ public function verify_persisted_svg_upload( $upload ) { if ( ! is_array( $upload ) || ! empty( $upload['error'] ) || empty( $upload['file'] ) ) { return $upload; } $filetype = wp_check_filetype( basename( $upload['file'] ) ); if ( ! in_array( $filetype['ext'] ?? '', [ 'svg', 'svgz' ], true ) ) { return $upload; } $contents = $this->read_file( $upload['file'] ); if ( 'svgz' === ( $filetype['ext'] ?? '' ) && false !== $contents ) { $contents = @gzdecode( $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } if ( is_string( $contents ) && '' !== trim( $contents ) && $this->is_valid_svg( $contents ) ) { return $upload; } $this->delete_current_upload( $upload['file'] ); $upload['error'] = $this->sanitization_error_message(); return $upload; } /** * Delete only a verified file from the current WordPress uploads tree. * * @param string $path Candidate upload path. * @return bool */ protected function delete_current_upload( $path ) { $uploads = wp_upload_dir(); $base = realpath( $uploads['basedir'] ?? '' ); $file = realpath( $path ); if ( false === $base || false === $file || 0 !== strpos( $file, rtrim( $base, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR ) ) { return false; } return unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink } /** * Sanitize a temporary SVG and replace it only when successful. * * @param string $path Current upload's temporary path. * @return bool */ protected function sanitize_svg_file( $path ) { $contents = $this->read_file( $path ); if ( false === $contents ) { return false; } $sanitized = $this->sanitize_svg_contents( $contents, $this->is_svg_gzipped( $contents ) ); if ( false === $sanitized ) { return false; } return strlen( $sanitized ) === $this->write_file( $path, $sanitized ); } /** * Apply the established sanitizer to SVG or SVGZ content. * * @param string $contents Raw upload bytes. * @param bool $is_gzipped Whether the source is gzip encoded. * @return string|false */ protected function sanitize_svg_contents( $contents, $is_gzipped = false ) { try { if ( ! is_string( $contents ) || '' === $contents ) { return false; } if ( $is_gzipped ) { $contents = gzdecode( $contents ); if ( false === $contents ) { return false; } } $sanitizer = $this->create_sanitizer(); $sanitizer->minify( true ); $sanitized = $sanitizer->sanitize( $contents ); if ( ! is_string( $sanitized ) || '' === trim( $sanitized ) || ! $this->is_valid_svg( $sanitized ) || ! $this->has_safe_resource_references( $sanitized ) ) { return false; } if ( $is_gzipped ) { $sanitized = gzencode( $sanitized ); if ( false === $sanitized ) { return false; } } return $sanitized; } catch ( \Throwable $exception ) { return false; } } /** * Confirm the sanitizer produced a usable SVG document. * * @param string $contents Sanitized XML. * @return bool */ protected function is_valid_svg( $contents ) { $previous = libxml_use_internal_errors( true ); $document = new \DOMDocument(); try { $loaded = $document->loadXML( $contents, LIBXML_NONET ); return $loaded && $document->documentElement && 'svg' === strtolower( $document->documentElement->localName ); } finally { libxml_clear_errors(); libxml_use_internal_errors( $previous ); } } /** * Confirm that sanitizer output has no executable or remote resource URLs. * * The sanitizer removes many unsafe values itself. This post-sanitization * check is deliberately limited to URL-bearing attributes and CSS url() * expressions, so visible text such as "javascript: tutorial" remains valid. * * @param string $contents Sanitized SVG XML. * @return bool */ protected function has_safe_resource_references( string $contents ): bool { $previous = libxml_use_internal_errors( true ); $document = new \DOMDocument(); try { if ( ! $document->loadXML( $contents, LIBXML_NONET ) ) { return false; } foreach ( $document->getElementsByTagName( '*' ) as $element ) { foreach ( $element->attributes as $attribute ) { $name = strtolower( $attribute->localName ?: $attribute->nodeName ); $value = $attribute->value; if ( in_array( $name, [ 'href', 'src', 'data' ], true ) && $this->is_unsafe_resource_url( $value ) ) { return false; } if ( $this->has_unsafe_css_url( $value ) ) { return false; } } } return true; } catch ( \Throwable $exception ) { return false; } finally { libxml_clear_errors(); libxml_use_internal_errors( $previous ); } } /** * Whether a URL value uses an executable or remote scheme. * * @param string $value URL value. * @return bool */ protected function is_unsafe_resource_url( string $value ): bool { // Browsers ignore these ASCII controls while resolving a URL. $value = str_replace( [ "\t", "\n", "\r", "\f" ], '', trim( $value ) ); return 1 === preg_match( '/^(?:(?:java|vb)script\s*:|(?:(?:https?|ftp|file):)?\\/\\/)/i', $value ); } /** * Whether an attribute contains an unsafe CSS url() reference. * * @param string $value Attribute value. * @return bool */ protected function has_unsafe_css_url( string $value ): bool { if ( ! preg_match_all( '/url\(\s*([\'\"]?)(.*?)\1\s*\)/i', $value, $matches, PREG_SET_ORDER ) ) { return false; } foreach ( $matches as $match ) { if ( $this->is_unsafe_resource_url( $match[2] ) ) { return true; } } return false; } /** @return Sanitizer */ protected function create_sanitizer() { $sanitizer = new Sanitizer(); $sanitizer->removeRemoteReferences( true ); return $sanitizer; } /** @return string|false */ protected function read_file( $path ) { return file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } /** @return int|false */ protected function write_file( $path, $contents ) { return file_put_contents( $path, $contents, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents } /** @return string */ private function sanitization_error_message() { return __( 'The SVG could not be sanitized and was not uploaded.', 'admin-optimizer' ); } /** * Check if the contents are gzipped * * @see http://www.gzip.org/zlib/rfc-gzip.html#member-format * * @param string $contents Content to check. * * @return bool */ private function is_svg_gzipped( string $contents ) { // phpcs:disable Generic.Strings.UnnecessaryStringConcat.Found if ( function_exists( 'mb_strpos' ) ) { return 0 === mb_strpos( $contents, "\x1f" . "\x8b" . "\x08" ); } else { return 0 === strpos( $contents, "\x1f" . "\x8b" . "\x08" ); } // phpcs:enable } /** * Generate SVG metadata * * @param array $metadata SVG metadata. * @param int $attachment_id Attachment ID. * * @return mixed */ public function generate_svg_metadata( $metadata, $attachment_id ) { if ( 'image/svg+xml' === get_post_mime_type( $attachment_id ) ) { $svg_path = get_attached_file( $attachment_id ); $svg = simplexml_load_file( $svg_path ); $width = 0; $height = 0; if ( $svg ) { $attributes = $svg->attributes(); if ( isset( $attributes->width, $attributes->height ) ) { $width = intval( floatval( $attributes->width ) ); $height = intval( floatval( $attributes->height ) ); } elseif ( isset( $attributes->viewBox ) ) { //phpcs:ignore $sizes = explode( ' ', $attributes->viewBox ); //phpcs:ignore if ( isset( $sizes[2], $sizes[3] ) ) { $width = intval( floatval( $sizes[2] ) ); $height = intval( floatval( $sizes[3] ) ); } } } $metadata['width'] = $width; $metadata['height'] = $height; // Get SVG filename. $svg_url = wp_get_original_image_url( $attachment_id ); $svg_url_path = str_replace( wp_upload_dir()['baseurl'] . '/', '', $svg_url ); $metadata['file'] = $svg_url_path; } return $metadata; } /** * Disable the creation of srcset on SVG images. * * @param array $image_meta The image meta data. * @param int[] $size_array { * An array of requested width and height values. * * @type int $0 The width in pixels. * @type int $1 The height in pixels. * } * @param string $image_src The 'src' of the image. * @param int $attachment_id The image attachment ID. */ public function disable_svg_srcset( $image_meta, $size_array, $image_src, $attachment_id ) { if ( $attachment_id && 'image/svg+xml' === get_post_mime_type( $attachment_id ) && is_array( $image_meta ) ) { $image_meta['sizes'] = []; } return $image_meta; } /** * Return svg file URL to show preview in media library * * @link https://developer.wordpress.org/reference/functions/wp_prepare_attachment_for_js/ * @since 2.6.0 * * @param array $response Response. */ public function get_svg_url_in_media_library( $response ) { // Check response mime type. if ( 'image/svg+xml' === $response['mime'] ) { $response['image'] = [ 'src' => $response['url'], ]; } return $response; } /** * Filters the attachment meta data. * * @param array|bool $data Array of meta data for the given attachment, or false * if the object does not exist. * @param int $post_id Attachment ID. */ public function metadata_error_fix( $data, $post_id ) { // If it's a WP_Error regenerate metadata and save it. if ( is_wp_error( $data ) ) { $data = wp_generate_attachment_metadata( $post_id, get_attached_file( $post_id ) ); wp_update_attachment_metadata( $post_id, $data ); } return $data; } }