`, 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;
}
}