\esc_url( $url ) ); if ( ! empty( $tag['updated'] ) && \is_string( $tag['updated'] ) && \strtotime( $tag['updated'] ) ) { $block_attrs['updated'] = \sanitize_text_field( $tag['updated'] ); } $wrapped = \sprintf( '%s', \wp_json_encode( $block_attrs ), $shortcode ); // Case-insensitive replacement, avoid already wrapped shortcodes. $pattern = '/(?)' . \preg_quote( $shortcode, '/' ) . '(?!)/i'; $content = \preg_replace_callback( $pattern, function () use ( $wrapped ) { return $wrapped; }, $content ); } return $content; } /** * Generate an emoji img tag. * * @param string $url The emoji image URL. * @param string $name The emoji name (without colons). * * @return string The emoji img tag HTML. */ public static function get_img_tag( $url, $name ) { return \sprintf( '%s', \esc_url( $url ), \esc_attr( $name ), \esc_attr( $name ) ); } /** * Get the allowed HTML structure for emoji img tags. * * Used by Comment class for KSES validation of emoji in author names. * * @return array The allowed HTML structure for use with wp_kses. */ public static function get_kses_allowed_html() { return array( 'img' => array( 'class' => array( 'required' => true, 'values' => array( 'emoji' ), ), 'src' => array( 'required' => true, 'value_callback' => array( self::class, 'validate_emoji_src' ), ), 'alt' => array( 'required' => true ), 'title' => array( 'required' => true ), 'height' => array( 'required' => true, 'values' => array( '20' ), ), 'width' => array( 'required' => true, 'values' => array( '20' ), ), 'draggable' => array( 'required' => true, 'values' => array( 'false' ), ), ), ); } /** * Validate emoji src attribute for wp_kses. * * By default, only allows locally cached emoji URLs for privacy. * Remote URLs are only allowed when caching is explicitly disabled. * * @param string $value The src attribute value. * * @return bool True if the src is valid, false otherwise. */ public static function validate_emoji_src( $value ) { $upload_dir = \wp_upload_dir(); $emoji_base = $upload_dir['baseurl'] . '/activitypub/emoji/'; // Allow local cached emoji. if ( \str_starts_with( $value, $emoji_base ) ) { return true; } // Only allow remote URLs when caching is explicitly disabled. // This protects user privacy by defaulting to local-only emoji. $allow_remote = ! Cache::is_enabled(); // Validate the URL format if remote is allowed. if ( $allow_remote ) { $allow_remote = (bool) \wp_http_validate_url( $value ); } /** * Filters whether a remote emoji URL is valid. * * Use this filter to explicitly allow remote emoji URLs when needed * (e.g., for CDN proxying). * * @since 5.6.0 * * @param bool $valid Whether the URL is valid. * @param string $value The emoji src URL. */ return \apply_filters( 'activitypub_validate_emoji_src', $allow_remote, $value ); } /** * Prepare actor meta for emoji storage. * * Used for storing actor emoji data for comment author name rendering. * * @param array $actor The actor array containing potential emoji in tags. * * @return array Meta input array with emoji data, or empty array if no emoji. */ public static function prepare_actor_meta( $actor ) { if ( empty( $actor['tag'] ) || ! \is_array( $actor['tag'] ) ) { return array(); } $emoji_tags = \array_values( \array_filter( $actor['tag'], function ( $tag ) { return \is_array( $tag ) && isset( $tag['type'] ) && 'Emoji' === $tag['type']; } ) ); if ( empty( $emoji_tags ) ) { return array(); } return array( '_activitypub_emoji' => \wp_json_encode( $emoji_tags ), ); } /** * Replace emoji from stored JSON data. * * Used for comment author name replacement at display time. * * @param string $text The text to process. * @param string $emoji_json JSON-encoded emoji tag data. * * @return string The processed text with emoji replacements. */ public static function replace_from_json( $text, $emoji_json ) { $tags = \json_decode( $emoji_json, true ); if ( empty( $tags ) || ! \is_array( $tags ) ) { return $text; } foreach ( $tags as $tag ) { if ( empty( $tag['name'] ) ) { continue; } $url = object_to_uri( $tag['icon'] ?? null ); if ( empty( $url ) ) { continue; } /** * Filters a remote media URL for caching. * * @param string $url The remote media URL. * @param string $context The context ('emoji'). * @param string|null $entity_id The entity ID. * @param array $options Additional options. */ $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'emoji', null, array( 'updated' => $tag['updated'] ?? null ) ); $name = \trim( $tag['name'], ':' ); $img = self::get_img_tag( $cached_url ?: $url, $name ); $text = \str_ireplace( $tag['name'], $img, $text ); } return $text; } /** * Replace emoji in text using a remote actor's stored emoji data. * * Used by Mailer class for actor name/summary in emails. * * @param string $text The text to process. * @param string $actor_url The actor's URL to look up emoji data. * * @return string The processed text with emoji replacements. */ public static function replace_for_actor( $text, $actor_url ) { $actor_post = Collection\Remote_Actors::get_by_uri( $actor_url ); if ( ! $actor_post || \is_wp_error( $actor_post ) ) { return $text; } $emoji_data = \get_post_meta( $actor_post->ID, '_activitypub_emoji', true ); if ( empty( $emoji_data ) ) { return $text; } return self::replace_from_json( $text, $emoji_data ); } }