* * @copyright Copyright (C) 2008-2019, Yoast BV * The following code is a derivative work of the code from the Yoast(https://github.com/Yoast/wordpress-seo/), which is licensed under GPL v3. */ namespace RankMath\OpenGraph; use RankMath\Helper; use RankMath\Post; use RankMath\Traits\Hooker; use RankMath\Helpers\Str; use RankMath\Helpers\Url; use RankMath\Helpers\Attachment; defined( 'ABSPATH' ) || exit; /** * Image class. */ class Image { use Hooker; /** * Holds network slug. * * @var array */ private $network; /** * Holds the images that have been put out as OG image. * * @var array */ private $images = []; /** * Holds the OpenGraph instance. * * @var OpenGraph */ private $opengraph; /** * The parameters we have for Facebook images. * * @var array */ private $usable_dimensions = [ 'min_width' => 200, 'max_width' => 2000, 'min_height' => 200, 'max_height' => 2000, ]; /** * The Constructor. * * @param string $image (Optional) The image URL. * @param OpenGraph $opengraph (Optional) The OpenGraph object.. */ public function __construct( $image = false, $opengraph = null ) { $this->opengraph = $opengraph; $this->network = $opengraph->network; // If an image was not supplied or could not be added. if ( Str::is_non_empty( $image ) ) { $this->add_image_by_url( $image ); } if ( ! post_password_required() ) { $this->set_images(); } } /** * Outputs the images. */ public function show() { foreach ( $this->get_images() as $image => $image_meta ) { $this->image_tag( $image_meta ); $this->image_meta( $image_meta ); } } /** * Return the images array. * * @return array */ public function get_images() { return $this->images; } /** * Check whether we have images or not. * * @return bool */ public function has_images() { return ! empty( $this->images ); } /** * Generate secret key for safer image URLs. * * @param int $id The attachment ID. * @param string $type Overlay type. */ public function generate_secret( $id, $type ) { return md5( $id . $type . wp_salt( 'nonce' ) ); } /** * Outputs an image tag based on whether it's https or not. * * @param array $image_meta Image metadata. */ private function image_tag( $image_meta ) { $overlay = $this->opengraph->get_overlay_image(); $og_image = $image_meta['url']; if ( $overlay && ! empty( $image_meta['id'] ) ) { $secret = $this->generate_secret( $image_meta['id'], $overlay ); $og_image = admin_url( "admin-ajax.php?action=rank_math_overlay_thumb&id={$image_meta['id']}&type={$overlay}&hash={$secret}" ); } $this->opengraph->tag( 'og:image', esc_url_raw( $og_image ) ); // Add secure URL if detected. Not all services implement this, so the regular one also needs to be rendered. if ( Str::starts_with( 'https://', $og_image ) ) { $this->opengraph->tag( 'og:image:secure_url', esc_url_raw( $og_image ) ); } } /** * Output the image metadata. * * @param array $image_meta Image meta data to output. */ private function image_meta( $image_meta ) { $image_tags = [ 'width', 'height', 'alt', 'type' ]; foreach ( $image_tags as $key ) { if ( ! empty( $image_meta[ $key ] ) ) { $this->opengraph->tag( 'og:image:' . $key, $image_meta[ $key ] ); } } } /** * Adds an image based on a given URL, and attempts to be smart about it. * * @param string $url The given URL. */ public function add_image_by_url( $url ) { if ( empty( $url ) ) { return; } $attachment_id = Attachment::get_by_url( $url ); if ( $attachment_id > 0 ) { $this->add_image_by_id( $attachment_id ); return; } $this->add_image( [ 'url' => $url ] ); } /** * Adds an image to the list by attachment ID. * * @param int $attachment_id The attachment ID to add. */ public function add_image_by_id( $attachment_id ) { if ( ! wp_attachment_is_image( $attachment_id ) ) { return; } $variations = $this->get_variations( $attachment_id ); // If we are left without variations, there is no valid variation for this attachment. if ( empty( $variations ) ) { return; } // The variations are ordered so the first variations is by definition the best one. $attachment = $variations[0]; if ( $attachment ) { // In the past `add_image` accepted an image url, so leave this for backwards compatibility. if ( Str::is_non_empty( $attachment ) ) { $attachment = [ 'url' => $attachment ]; } $attachment['alt'] = Attachment::get_alt_tag( $attachment_id ); $this->add_image( $attachment ); } } /** * Display an OpenGraph image tag. * * @param string $attachment Source URL to the image. */ public function add_image( $attachment = '' ) { // In the past `add_image` accepted an image url, so leave this for backwards compatibility. if ( Str::is_non_empty( $attachment ) ) { $attachment = [ 'url' => $attachment ]; } $validate_image = true; /** * Allow changing the OpenGraph image. * The dynamic part of the hook name, $this->network, is the network slug (facebook, twitter). * * @param string $img The image we are about to add. */ $filter_image_url = trim( $this->do_filter( "opengraph/{$this->network}/image", isset( $attachment['url'] ) ? $attachment['url'] : '' ) ); if ( ! empty( $filter_image_url ) && ( empty( $attachment['url'] ) || $filter_image_url !== $attachment['url'] ) ) { $attachment = [ 'url' => $filter_image_url ]; $validate_image = false; } /** * Secondary filter to allow changing the whole array. * The dynamic part of the hook name, $this->network, is the network slug (facebook, twitter). * This makes it possible to change the image ID too, to allow for image overlays. * * @param array $attachment The image we are about to add. */ $attachment = $this->do_filter( "opengraph/{$this->network}/image_array", $attachment ); if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) { return; } // Validate image only when it is not added using the opengraph filter. if ( $validate_image ) { $attachment_url = explode( '?', $attachment['url'] ); if ( ! empty( $attachment_url ) ) { $attachment['url'] = $attachment_url[0]; } // If the URL ends in `.svg`, we need to return. if ( ! $this->is_valid_image_url( $attachment['url'] ) ) { return; } } $image_url = $attachment['url']; if ( empty( $image_url ) ) { return; } if ( Url::is_relative( $image_url ) ) { $image_url = Attachment::get_relative_path( $image_url ); } if ( array_key_exists( $image_url, $this->images ) ) { return; } $attachment['url'] = $image_url; if ( empty( $attachment['alt'] ) && is_singular() ) { $attachment['alt'] = $this->get_attachment_alt(); } $this->images[ $image_url ] = $attachment; } /** * Get attachment alt with fallback * * @return string */ private function get_attachment_alt() { global $post; $focus_keywords = Helper::get_post_meta( 'focus_keyword', $post->ID ); if ( ! empty( $focus_keywords ) ) { $focus_keywords = explode( ',', $focus_keywords ); return $focus_keywords[0]; } return get_the_title(); } /** * Check if page is front page or singular and call the corresponding functions. */ private function set_images() { /** * Allow developers to add images to the OpenGraph tags. * * The dynamic part of the hook name. $this->network, is the network slug. * * @param Image The current object. */ $this->do_action( "opengraph/{$this->network}/add_images", $this ); switch ( true ) { case is_front_page(): $this->set_front_page_image(); break; case is_home(): $this->set_posts_page_image(); break; case is_attachment(): $this->set_attachment_page_image(); break; case is_singular() || Post::is_shop_page(): $this->set_singular_image(); break; case is_post_type_archive(): $this->set_archive_image(); break; case is_category(): case is_tag(): case is_tax(): $this->set_taxonomy_image(); break; case is_author(): $this->set_author_image(); break; } /** * Allow developers to add images to the OpenGraph tags. * * The dynamic part of the hook name. $this->network, is the network slug. * * @param Image The current object. */ $this->do_action( "opengraph/{$this->network}/add_additional_images", $this ); /** * Passing a truthy value to the filter will effectively short-circuit the * set default image process. * * @param bool $return Short-circuit return value. Either false or true. */ if ( false !== $this->do_filter( 'opengraph/pre_set_default_image', false ) ) { return; } // If not, get default image. $image_id = Helper::get_settings( 'titles.open_graph_image_id' ); if ( ! $this->has_images() ) { if ( $image_id > 0 ) { $this->add_image_by_id( $image_id ); return; } $this->add_image(); // This allows "opengraph/{$this->network}/image" filter to be used even if no image is set. } } /** * If the frontpage image exists, call `add_image`. * * @return void */ private function set_front_page_image() { $this->set_user_defined_image(); if ( $this->has_images() ) { return; } // If no frontpage image is found, don't add anything. if ( $image_id = Helper::get_settings( 'titles.homepage_facebook_image_id' ) ) { // phpcs:ignore $this->add_image_by_id( $image_id ); } } /** * Gets the user-defined image of the post. * * @param null|int $post_id The post ID to get the images for. */ private function set_user_defined_image( $post_id = null ) { if ( null === $post_id ) { $post_id = get_queried_object_id(); } $this->set_image_post_meta( $post_id ); if ( $this->has_images() ) { return; } $this->set_featured_image( $post_id ); } /** * If opengraph-image is set, call `add_image` and return true. * * @param int $post_id Optional post ID to use. */ private function set_image_post_meta( $post_id = 0 ) { if ( empty( $post_id ) ) { return; } $image_id = Helper::get_post_meta( "{$this->opengraph->prefix}_image_id", $post_id ); $this->add_image_by_id( $image_id ); } /** * Retrieve the featured image. * * @param int $post_id The post ID. */ private function set_featured_image( $post_id = null ) { /** * Passing a truthy value to the filter will effectively short-circuit the * set featured image process. * * @param bool $return Short-circuit return value. Either false or true. * @param int $post_id Post ID for the current post. */ if ( false !== $this->do_filter( 'opengraph/pre_set_featured_image', false, $post_id ) ) { return; } if ( $post_id && has_post_thumbnail( $post_id ) ) { $attachment_id = get_post_thumbnail_id( $post_id ); $this->add_image_by_id( $attachment_id ); } } /** * Get the images of the posts page. */ private function set_posts_page_image() { $post_id = get_option( 'page_for_posts' ); $this->set_image_post_meta( $post_id ); if ( $this->has_images() ) { return; } $this->set_featured_image( $post_id ); } /** * If this is an attachment page, call `add_image` with the attachment. */ private function set_attachment_page_image() { $post_id = get_queried_object_id(); if ( wp_attachment_is_image( $post_id ) ) { $this->add_image_by_id( $post_id ); } } /** * Get the images of the singular post. * * @param null|int $post_id The post ID to get the images for. */ private function set_singular_image( $post_id = null ) { $is_shop_page = Post::is_shop_page(); if ( $is_shop_page ) { $post_id = Post::get_shop_page_id(); } $post_id = is_null( $post_id ) ? get_queried_object_id() : $post_id; $this->set_user_defined_image( $post_id ); if ( $this->has_images() ) { return; } /** * Passing a truthy value to the filter will effectively short-circuit the * set content image process. * * @param bool $return Short-circuit return value. Either false or true. * @param int $post_id Post ID for the current post. */ if ( false !== $this->do_filter( 'opengraph/pre_set_content_image', false, $post_id ) ) { if ( $is_shop_page ) { $this->set_archive_image(); } return; } $this->set_content_image( get_post( $post_id ) ); if ( ! $this->has_images() && $is_shop_page ) { $this->set_archive_image(); } } /** * Adds the first usable attachment image from the post content. * * @param object $post The post object. */ private function set_content_image( $post ) { $content = sanitize_post_field( 'post_content', $post->post_content, $post->ID ); // Early bail! if ( '' === $content || false === Str::contains( 'do_filter( 'opengraph/content_image_cache', true ); if ( $do_og_content_image_cache ) { $cache_key = 'rank_math_og_content_image'; $cache = get_post_meta( $post->ID, $cache_key, true ); $check = md5( $post->post_content ); if ( ! empty( $cache ) && isset( $cache['check'] ) && $check === $cache['check'] ) { foreach ( $cache['images'] as $image ) { if ( is_int( $image ) ) { $this->add_image_by_id( $image ); } else { $this->add_image( $image ); } } return; } $cache = [ 'check' => $check, 'images' => [], ]; } $images = []; if ( preg_match_all( '`]+>`', $content, $matches ) ) { foreach ( $matches[0] as $img ) { if ( preg_match( '`src=(["\'])(.*?)\1`', $img, $match ) ) { if ( isset( $match[2] ) ) { $images[] = $match[2]; } } } } $images = array_unique( $images ); if ( empty( $images ) ) { return; } foreach ( $images as $image ) { // If an image has been added, we're done. if ( $this->has_images() ) { break; } if ( Url::is_external( $image ) ) { $this->add_image( $image ); } else { $attachment_id = Attachment::get_by_url( $image ); if ( 0 === $attachment_id ) { $this->add_image( $image ); if ( $do_og_content_image_cache ) { $cache['images'][] = $image; } } else { $this->add_image_by_id( $attachment_id ); if ( $do_og_content_image_cache ) { $cache['images'][] = $attachment_id; } } } } if ( $do_og_content_image_cache ) { update_post_meta( $post->ID, $cache_key, $cache ); } } /** * Check if Author has an image and add this image. */ private function set_author_image() { $image_id = Helper::get_user_meta( "{$this->opengraph->prefix}_image_id" ); $this->add_image_by_id( $image_id ); } /** * Check if taxonomy has an image and add this image. */ private function set_taxonomy_image() { $image_id = Helper::get_term_meta( "{$this->opengraph->prefix}_image_id" ); $this->add_image_by_id( $image_id ); } /** * Check if archive has an image and add this image. */ private function set_archive_image() { $post_type = get_query_var( 'post_type' ); $image_id = Helper::get_settings( "titles.pt_{$post_type}_facebook_image_id" ); $this->add_image_by_id( $image_id ); } /** * Determines whether the passed URL is considered valid. * * @param string $url The URL to check. * * @return bool Whether or not the URL is a valid image. */ protected function is_valid_image_url( $url ) { if ( ! is_string( $url ) ) { return false; } $check = wp_check_filetype( $url ); if ( empty( $check['ext'] ) ) { return false; } $extensions = [ 'jpeg', 'jpg', 'gif', 'png', 'webp' ]; return in_array( $check['ext'], $extensions, true ); } /** * Returns the different image variations for consideration. * * @param int $attachment_id The attachment to return the variations for. * * @return array The different variations possible for this attachment ID. */ public function get_variations( $attachment_id ) { $variations = []; /** * Determines which image sizes we'll loop through to get an appropriate image. * * @param unsigned array - The array of image sizes to loop through. */ $sizes = $this->do_filter( 'opengraph/image_sizes', [ 'full', 'large', 'medium_large' ] ); foreach ( $sizes as $size ) { if ( $variation = $this->get_attachment_image( $attachment_id, $size ) ) { // phpcs:ignore if ( $this->has_usable_dimensions( $variation ) ) { $variations[] = $variation; } } } return $variations; } /** * Retrieve an image to represent an attachment. * * @param int $attachment_id Image attachment ID. * @param string|array $size Optional. Image size. Accepts any valid image size, or an array of width * and height values in pixels (in that order). Default 'thumbnail'. * @return false|array */ private function get_attachment_image( $attachment_id, $size = 'thumbnail' ) { $image = wp_get_attachment_image_src( $attachment_id, $size ); // Early Bail! if ( ! $image ) { return false; } list( $src, $width, $height ) = $image; return [ 'id' => $attachment_id, 'url' => $src, 'width' => $width, 'height' => $height, 'type' => get_post_mime_type( $attachment_id ), 'alt' => Attachment::get_alt_tag( $attachment_id ), ]; } /** * Checks whether an img sizes up to the parameters. * * @param array $dimensions The image values. * @return bool True if the image has usable measurements, false if not. */ private function has_usable_dimensions( $dimensions ) { foreach ( [ 'width', 'height' ] as $param ) { $minimum = $this->usable_dimensions[ 'min_' . $param ]; $maximum = $this->usable_dimensions[ 'max_' . $param ]; $current = $dimensions[ $param ]; if ( ( $current < $minimum ) || ( $current > $maximum ) ) { return false; } } return true; } }