`, so SVG markup is never inlined into a page. */ class CustomIcons { const META_KEY = '_ablocks_custom_icon'; const QUERY_PARAM = 'ablocks_custom_icon'; /** * Formats an icon can be. Mirrors ALLOWED_ICON_MIME_TYPES in * src/controls/icon-upload/custom-icons.js. */ const ALLOWED_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', ]; public static function init() { $self = new self(); add_action( 'init', [ $self, 'register_meta' ] ); add_filter( 'rest_attachment_collection_params', [ $self, 'add_collection_param' ] ); add_filter( 'rest_attachment_query', [ $self, 'filter_rest_query' ], 10, 2 ); add_filter( 'rest_pre_insert_attachment', [ $self, 'validate_rest_flag' ], 10, 2 ); add_filter( 'add_post_metadata', [ $self, 'guard_flag' ], 10, 4 ); add_filter( 'update_post_metadata', [ $self, 'guard_flag' ], 10, 4 ); add_filter( 'render_block', [ $self, 'filter_render_block' ], 10, 2 ); } /** * Image formats that can carry transparency, so a single-color icon can be * painted through its alpha channel. JPEG is opaque and is never recolored. */ const RECOLOR_EXTENSIONS = [ 'svg', 'png', 'gif', 'webp', 'avif' ]; /** * Point a saved image icon at its attachment's current file, at render time. * * Saved post content is never touched. Only an `` * whose `src` is one of this block's own `{prefix}ImageUrl` attributes is * considered, and only two things are done to it: * * - The attachment (`{prefix}ImageID`) still exists and serves the same file * name under a different URL (site moved, uploads path changed): use the * current URL. Matching on the file name is what keeps a block pasted from * another site — whose ID means some unrelated local attachment — untouched. * - The URL points into this site's uploads and the file is gone (attachment * deleted): hide the image instead of showing a broken one. * * Anything else, including URL-only data and external URLs, renders exactly * as before. * * @param string $block_content Rendered block HTML. * @param array $block Parsed block. * @return string */ public function filter_render_block( $block_content, $block ) { if ( ! is_string( $block_content ) || false === strpos( $block_content, 'ablocks-image-icon' ) || empty( $block['blockName'] ) || 0 !== strpos( $block['blockName'], 'ablocks/' ) || empty( $block['attrs'] ) || ! class_exists( '\WP_HTML_Tag_Processor' ) ) { return $block_content; } $refs = self::collect_image_refs( $block['attrs'] ); if ( ! $refs ) { return $block_content; } $processor = new \WP_HTML_Tag_Processor( $block_content ); $changed = false; while ( $processor->next_tag( [ 'tag_name' => 'IMG', 'class_name' => 'ablocks-image-icon' ] ) ) { $src = $processor->get_attribute( 'src' ); if ( ! is_string( $src ) || ! array_key_exists( $src, $refs ) ) { continue; } $current = self::resolve_url( $src, $refs[ $src ] ); if ( $current !== $src ) { $processor->set_attribute( 'src', $current ); $changed = true; } elseif ( self::is_missing_local_file( $src ) ) { $processor->set_attribute( 'hidden', true ); $changed = true; } } return $changed ? $processor->get_updated_html() : $block_content; } /** * Every `{prefix}ImageUrl` in a block's attributes (at any depth, e.g. the * per-item icons of Image Hotspot), mapped to its `{prefix}ImageID` or 0. * * @param array $attrs Block attributes. * @return array */ public static function collect_image_refs( $attrs ) { $refs = []; if ( ! is_array( $attrs ) ) { return $refs; } foreach ( $attrs as $key => $value ) { if ( is_array( $value ) ) { $refs += self::collect_image_refs( $value ); } elseif ( is_string( $key ) && is_string( $value ) && '' !== $value && 'ImageUrl' === substr( $key, -8 ) ) { $id_key = substr( $key, 0, -8 ) . 'ImageID'; $refs[ $value ] = isset( $attrs[ $id_key ] ) ? absint( $attrs[ $id_key ] ) : 0; } } return $refs; } /** * The attachment's current URL for the same file the saved URL names, or * the saved URL unchanged when that cannot be established. * * @param string $url Saved URL. * @param int $attachment_id Saved attachment ID (0 when none). * @return string */ public static function resolve_url( $url, $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( ! is_string( $url ) || '' === $url || ! $attachment_id || ! self::is_allowed_attachment( $attachment_id ) ) { return $url; } $file = wp_basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ); if ( '' === $file ) { return $url; } $full = wp_get_attachment_url( $attachment_id ); if ( ! $full ) { return $url; } $candidates = [ wp_basename( $full ) => $full ]; $meta = wp_get_attachment_metadata( $attachment_id ); if ( is_array( $meta ) ) { $dir = trailingslashit( dirname( $full ) ); if ( ! empty( $meta['original_image'] ) ) { $candidates[ wp_basename( $meta['original_image'] ) ] = $dir . wp_basename( $meta['original_image'] ); } if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size ) { if ( ! empty( $size['file'] ) ) { $candidates[ wp_basename( $size['file'] ) ] = $dir . wp_basename( $size['file'] ); } } } } return isset( $candidates[ $file ] ) ? $candidates[ $file ] : $url; } /** * Whether a URL points into this site's uploads at a file that no longer * exists. Anything outside the uploads directory is never judged missing. * * @param string $url Image URL. * @return bool */ public static function is_missing_local_file( $url ) { $uploads = wp_get_upload_dir(); if ( ! empty( $uploads['error'] ) || empty( $uploads['baseurl'] ) ) { return false; } // Compare without the scheme: content saved over http is still local // on a site now served over https. $base = preg_replace( '#^https?:#i', '', untrailingslashit( $uploads['baseurl'] ) ) . '/'; $path = preg_replace( '#^https?:#i', '', strtok( (string) $url, '?#' ) ); if ( 0 !== strpos( $path, $base ) ) { return false; } $relative = rawurldecode( substr( $path, strlen( $base ) ) ); if ( '' === $relative || false !== strpos( $relative, '..' ) ) { return false; } return ! file_exists( trailingslashit( $uploads['basedir'] ) . $relative ); } /** * @param string $url Image URL. * @param int $attachment_id Attachment ID (0 when none). * @return bool */ public static function supports_recolor( $url, $attachment_id = 0 ) { $extension = strtolower( pathinfo( (string) wp_parse_url( (string) $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); if ( '' !== $extension ) { return in_array( $extension, self::RECOLOR_EXTENSIONS, true ); } $mime = $attachment_id ? get_post_mime_type( absint( $attachment_id ) ) : ''; return in_array( $mime, [ 'image/svg+xml', 'image/png', 'image/gif', 'image/webp', 'image/avif' ], true ); } /** * CSS that paints an image icon's silhouette in a solid color: the image * becomes a mask over the element's own background, and its pixels are * moved out of view. The `` itself is unchanged, so it keeps its size, * and is still never inlined as markup. * * @param string $url Saved image URL. * @param int $attachment_id Saved attachment ID (0 when none). * @param string $color CSS color. * @return array Empty when the image cannot be recolored. */ public static function get_recolor_css( $url, $attachment_id, $color ) { if ( ! self::supports_recolor( $url, $attachment_id ) ) { return []; } $current = self::resolve_url( $url, $attachment_id ); if ( $current === $url && self::is_missing_local_file( $url ) ) { return []; } $css_url = self::css_url( $current ); if ( '' === $css_url ) { return []; } $mask = $css_url . ' center / contain no-repeat'; return [ 'background-color' => $color, '-webkit-mask' => $mask, 'mask' => $mask, 'object-position' => '-99999px 99999px', ]; } /** * A URL as a quoted CSS `url()`; empty when it is not a usable URL. Quotes, * parentheses, backslashes, semicolons and whitespace are percent-encoded, * so the value cannot leave the `url()`. * * @param string $url URL. * @return string */ public static function css_url( $url ) { $url = esc_url_raw( (string) $url ); if ( '' === $url ) { return ''; } $url = strtr( $url, [ '"' => '%22', "'" => '%27', '(' => '%28', ')' => '%29', '\\' => '%5C', ';' => '%3B', ' ' => '%20', "\t" => '', "\n" => '', "\r" => '', ] ); return 'url("' . $url . '")'; } public function register_meta() { register_post_meta( 'attachment', self::META_KEY, [ 'type' => 'boolean', 'single' => true, 'default' => false, 'show_in_rest' => true, 'sanitize_callback' => 'rest_sanitize_boolean', 'auth_callback' => [ __CLASS__, 'can_flag' ], ] ); } /** * Only someone who may edit the attachment can flag it, and only an image * in an allowed format can be flagged — a PDF or a video never becomes an * icon, whatever the request says. * * This is the capability half only: a role holding the primitive * `edit_post_meta` cap (some plugins grant it to admins) never reaches it, * so the format rule is enforced again in guard_flag(). * * @param bool $allowed Whether the user can edit the meta (ignored). * @param string $meta_key Meta key. * @param int $post_id Attachment ID. * @return bool */ public static function can_flag( $allowed, $meta_key = '', $post_id = 0 ) { return current_user_can( 'edit_post', $post_id ) && self::is_allowed_attachment( $post_id ); } /** * @param int $attachment_id Attachment ID. * @return bool */ public static function is_allowed_attachment( $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( ! $attachment_id || 'attachment' !== get_post_type( $attachment_id ) ) { return false; } return in_array( get_post_mime_type( $attachment_id ), self::ALLOWED_MIME_TYPES, true ); } /** * Refuse to flag a non-image over REST with a clear error, before the * attachment is touched. * * @param \stdClass $prepared_post Attachment about to be saved. * @param \WP_REST_Request $request Request. * @return \stdClass|\WP_Error */ public function validate_rest_flag( $prepared_post, $request ) { $meta = $request->get_param( 'meta' ); if ( is_array( $meta ) && ! empty( $meta[ self::META_KEY ] ) && rest_sanitize_boolean( $meta[ self::META_KEY ] ) && ! self::is_allowed_attachment( $request->get_param( 'id' ) ) ) { return new \WP_Error( 'ablocks_custom_icon_invalid_type', __( 'Only PNG, JPG, GIF, WebP, AVIF and SVG images can be added to the icon library.', 'ablocks' ), [ 'status' => 400 ] ); } return $prepared_post; } /** * Whatever the write path (REST, `update_post_meta`, an importer), the flag * is never set on an attachment that is not an allowed image. * * @param null|bool $check Short-circuit value. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @return null|bool */ public function guard_flag( $check, $object_id, $meta_key, $meta_value ) { if ( self::META_KEY !== $meta_key || ! rest_sanitize_boolean( $meta_value ) || self::is_allowed_attachment( $object_id ) ) { return $check; } return false; } public function add_collection_param( $params ) { $params[ self::QUERY_PARAM ] = [ 'description' => __( 'Limit results to images added to the aBlocks icon library.', 'ablocks' ), 'type' => 'boolean', ]; return $params; } /** * `GET /wp/v2/media?ablocks_custom_icon=1` lists the library. * * @param array $args WP_Query arguments. * @param \WP_REST_Request $request Request. * @return array */ public function filter_rest_query( $args, $request ) { if ( ! rest_sanitize_boolean( $request->get_param( self::QUERY_PARAM ) ) ) { return $args; } $meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : []; $meta_query[] = [ 'key' => self::META_KEY, 'value' => '1', ]; $args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $args['post_mime_type'] = self::ALLOWED_MIME_TYPES; return $args; } }