is_valid_url( $cached_url ) ) { $this->log( 'cache_hit', array( 'post_id' => $post_id, 'url' => $cached_url, ) ); return esc_url_raw( $cached_url ); } $this->log( 'cache_miss', array( 'post_id' => $post_id ) ); // No valid cache, resolve image. return $this->resolve_image( $post_id ); } /** * Resolve fallback image for a post. * * Detects featured image format and finds compatible alternative if needed. * * @since 1.0.0 * * @param int $post_id Post ID. * @return string Resolved image URL or empty string. */ public function resolve_image( int $post_id ): string { // Get featured image ID. $image_id = get_post_thumbnail_id( $post_id ); if ( empty( $image_id ) ) { // No featured image, check for global fallback. $this->log( 'no_featured_image', array( 'post_id' => $post_id ) ); return $this->get_global_fallback(); } // Get image URL. $image_url = wp_get_attachment_url( $image_id ); if ( empty( $image_url ) ) { $this->log( 'no_attachment_url', array( 'post_id' => $post_id, 'image_id' => $image_id, ) ); return $this->get_global_fallback(); } // Detect file extension. $url_path = wp_parse_url( $image_url, PHP_URL_PATH ); $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' ); $extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : ''; // If already a compatible format, save and return. if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) { $this->log( 'compatible_format', array( 'post_id' => $post_id, 'url' => $image_url, ) ); $this->save_cache( $post_id, $image_url ); return esc_url_raw( $image_url ); } // Modern format detected (webp, avif, etc.), find alternative. if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) { $this->log( 'modern_format_detected', array( 'post_id' => $post_id, 'extension' => $extension, 'url' => $image_url, ) ); $alternative = $this->find_compatible_alternative( $image_url ); if ( ! empty( $alternative ) ) { $this->log( 'alternative_found', array( 'post_id' => $post_id, 'original' => $image_url, 'alternative' => $alternative, ) ); $this->save_cache( $post_id, $alternative ); return esc_url_raw( $alternative ); } $this->log( 'no_alternative_found', array( 'post_id' => $post_id, 'url' => $image_url, ) ); } // No alternative found, return original (don't cache to allow retry on next request). return esc_url_raw( $image_url ); } /** * Find compatible image alternative by checking .jpg and .png versions. * * Uses HTTP HEAD requests to verify file existence. * Respects the `robotstxt_og_external_image_enabled` filter for external images. * * @since 1.0.0 * * @param string $image_url Original image URL. * @return string Compatible image URL or empty string if not found. */ public function find_compatible_alternative( string $image_url ): string { // Check if external images are allowed. if ( $this->is_external_url( $image_url ) ) { /** * Filter whether to attempt resolution for external images. * * @since 1.0.0 * * @param bool $enabled Whether external image resolution is enabled. * @param string $image_url The external image URL being resolved. */ $enabled = apply_filters( 'robotstxt_og_external_image_enabled', true, $image_url ); if ( ! $enabled ) { $this->log( 'external_image_skipped', array( 'url' => $image_url ) ); return ''; } } // Strip query string for URL manipulation. $clean_url = strtok( $image_url, '?' ); if ( ! is_string( $clean_url ) ) { return ''; } // Parse URL and get base path without extension. $url_path = wp_parse_url( $clean_url, PHP_URL_PATH ); $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' ); $filename = $path_info['filename']; $dir = $path_info['dirname'] ?? ''; if ( empty( $filename ) ) { return ''; } // Reconstruct base URL. $parsed = wp_parse_url( $clean_url ); $base_url = ( $parsed['scheme'] ?? 'https' ) . '://' . ( $parsed['host'] ?? '' ); if ( ! empty( $parsed['port'] ) ) { $base_url .= ':' . $parsed['port']; } $base_url .= trailingslashit( $dir ) . $filename; // Try .jpg first, then .png. $alternatives = array( '.jpg', '.png', '.jpeg' ); foreach ( $alternatives as $ext ) { $test_url = $base_url . $ext; if ( $this->url_exists( $test_url ) ) { return $test_url; } } return ''; } /** * Check whether a URL is safe to make server-side HTTP requests to. * * Rejects non-http(s) schemes and URLs that resolve to private or * reserved IP ranges to prevent Server-Side Request Forgery (SSRF). * * @since 1.0.3 * * @param string $url URL to validate. * @return bool True if the URL is safe to request. */ private function is_safe_url( string $url ): bool { $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { return false; } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return false; } // Strip IPv6 brackets for validation. $host_bare = trim( $host, '[]' ); // If the host is already an IP address, validate it directly. if ( filter_var( $host_bare, FILTER_VALIDATE_IP ) !== false ) { return (bool) filter_var( $host_bare, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } // Resolve hostname to IP and validate it is not private/reserved. $resolved = gethostbyname( $host_bare ); // gethostbyname() returns the original string on failure. if ( $resolved === $host_bare ) { return false; } return (bool) filter_var( $resolved, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } /** * Check if a URL exists using HTTP HEAD request. * * Uses negative caching to avoid repeated requests for non-existent URLs. * URLs resolving to private or reserved IP ranges are rejected (SSRF protection). * * @since 1.0.0 * * @param string $url URL to check. * @return bool True if URL returns 200-299 status code. */ private function url_exists( string $url ): bool { // Reject URLs that resolve to private/reserved IP ranges (SSRF protection). if ( ! $this->is_safe_url( $url ) ) { $this->log( 'ssrf_blocked', array( 'url' => $url ) ); return false; } // Check negative cache to avoid repeated failed requests. $cache_key = self::NEGATIVE_CACHE_PREFIX . md5( $url ); if ( false !== get_transient( $cache_key ) ) { $this->log( 'negative_cache_hit', array( 'url' => $url ) ); return false; } /** * Filter the timeout in seconds for external image HEAD requests. * * @since 1.0.0 * * @param int $timeout The timeout in seconds. * @param string $url The URL being checked. */ $timeout = (int) apply_filters( 'robotstxt_og_external_image_timeout', 5, $url ); // Use WordPress HTTP API. $response = wp_remote_head( $url, array( 'timeout' => max( 1, $timeout ), 'redirection' => 5, 'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ), ) ); // Handle WP_Error (network failure, timeout, etc.). if ( is_wp_error( $response ) ) { $this->log( 'head_request_error', array( 'url' => $url, 'error' => $response->get_error_message(), ) ); set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL ); return false; } // Check response code. $code = (int) wp_remote_retrieve_response_code( $response ); $exists = $code >= 200 && $code < 300; if ( ! $exists ) { $this->log( 'head_request_failed', array( 'url' => $url, 'code' => $code, ) ); set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL ); } else { $this->log( 'head_request_success', array( 'url' => $url, 'code' => $code, ) ); } return $exists; } /** * Check if a URL is external (hosted on a different domain). * * @since 1.0.0 * * @param string $url URL to check. * @return bool True if the URL is external. */ private function is_external_url( string $url ): bool { $site_host = wp_parse_url( get_bloginfo( 'url' ), PHP_URL_HOST ); $image_host = wp_parse_url( $url, PHP_URL_HOST ); if ( empty( $site_host ) || empty( $image_host ) ) { return false; } return strtolower( $site_host ) !== strtolower( $image_host ); } /** * Get global fallback image URL from settings. * * If the stored image is in an incompatible format (AVIF, WebP, etc.), * attempts to resolve a JPEG/PNG alternative. Returns empty string if * no compatible image is found. * * @since 1.0.0 * * @return string Compatible image URL or empty string. */ private function get_global_fallback(): string { $option_value = get_option( 'robotstxt_og_fallback_image', '' ); $fallback_url = is_string( $option_value ) ? $option_value : ''; if ( empty( $fallback_url ) || ! $this->is_valid_url( $fallback_url ) ) { return ''; } return esc_url_raw( $fallback_url ); } /** * Get the configured homepage image URL. * * If the stored image is in an incompatible format (AVIF, WebP, etc.), * attempts to resolve a JPEG/PNG alternative before falling back to * the global fallback image. * * @since 1.1.0 * * @return string Compatible image URL or empty string. */ public function get_homepage_image(): string { $option_value = get_option( 'robotstxt_og_homepage_image', '' ); $image_url = is_string( $option_value ) ? $option_value : ''; if ( ! empty( $image_url ) && $this->is_valid_url( $image_url ) ) { return esc_url_raw( $image_url ); } // Fall back to global fallback image. return $this->get_global_fallback(); } /** * Ensure an image URL is in a format compatible with social media crawlers. * * Returns the URL unchanged for JPEG/PNG. For incompatible formats * (AVIF, WebP, GIF, SVG, BMP, TIFF), attempts to find a JPEG/PNG * alternative via find_compatible_alternative(). Returns empty string * if no compatible version can be found. * * @since 1.2.0 * * @param string $image_url Image URL to check. * @return string Compatible image URL or empty string. */ public function ensure_compatible_format( string $image_url ): string { $url_path = wp_parse_url( $image_url, PHP_URL_PATH ); $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' ); $extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : ''; // Already a compatible format. if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) { return esc_url_raw( $image_url ); } // Incompatible modern format — try to find a JPEG/PNG alternative. if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) { $this->log( 'fallback_image_incompatible_format', array( 'url' => $image_url, 'extension' => $extension, ) ); $alternative = $this->find_compatible_alternative( $image_url ); if ( ! empty( $alternative ) ) { $this->log( 'fallback_image_alternative_found', array( 'original' => $image_url, 'alternative' => $alternative, ) ); return esc_url_raw( $alternative ); } $this->log( 'fallback_image_no_alternative', array( 'url' => $image_url ) ); // No compatible alternative found — discard this image. return ''; } // Unknown extension — return as-is and let the caller decide. return esc_url_raw( $image_url ); } /** * Save resolved URL to postmeta cache. * * @since 1.0.0 * * @param int $post_id Post ID. * @param string $url Resolved image URL. * @return void */ private function save_cache( int $post_id, string $url ): void { update_post_meta( $post_id, '_og_image_fallback_url', esc_url_raw( $url ) ); } /** * Clear cached fallback URL for a post. * * @since 1.0.0 * * @param int $post_id Post ID. * @return void */ public function clear_cache( int $post_id ): void { delete_post_meta( $post_id, '_og_image_fallback_url' ); } /** * Get fallback image URL for a taxonomy term. * * Checks term meta cache first, then looks for a term image set by * supported plugins (e.g., Yoast SEO term images, custom term meta). * * @since 1.0.0 * * @param int $term_id Term ID. * @return string Image URL or empty string if not found. */ public function get_taxonomy_fallback_image( int $term_id ): string { // Check term meta cache first. $cached_url = get_term_meta( $term_id, '_og_image_fallback_url', true ); if ( is_string( $cached_url ) && ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) { $this->log( 'taxonomy_cache_hit', array( 'term_id' => $term_id, 'url' => $cached_url, ) ); return esc_url_raw( $cached_url ); } $this->log( 'taxonomy_cache_miss', array( 'term_id' => $term_id ) ); return $this->resolve_taxonomy_image( $term_id ); } /** * Resolve fallback image for a taxonomy term. * * Checks for term images set by Yoast SEO or other plugins via a filter. * * @since 1.0.0 * * @param int $term_id Term ID. * @return string Resolved image URL or empty string. */ public function resolve_taxonomy_image( int $term_id ): string { /** * Filter the image URL for a taxonomy term. * * Use this filter to integrate with plugins that assign images to * taxonomy terms (e.g., custom term meta, theme functions). * * @since 1.0.0 * * @param string $image_url The image URL. Empty string if none. * @param int $term_id The term ID being resolved. */ $image_url = (string) apply_filters( 'robotstxt_og_taxonomy_image', '', $term_id ); if ( empty( $image_url ) || ! $this->is_valid_url( $image_url ) ) { return $this->get_global_fallback(); } // Detect file extension and resolve if needed. $url_path = wp_parse_url( $image_url, PHP_URL_PATH ); $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' ); $extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : ''; if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) { update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $image_url ) ); return esc_url_raw( $image_url ); } if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) { $alternative = $this->find_compatible_alternative( $image_url ); if ( ! empty( $alternative ) ) { update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $alternative ) ); return esc_url_raw( $alternative ); } } return esc_url_raw( $image_url ); } /** * Clear cached fallback URL for a taxonomy term. * * @since 1.0.0 * * @param int $term_id Term ID. * @return void */ public function clear_taxonomy_cache( int $term_id ): void { delete_term_meta( $term_id, '_og_image_fallback_url' ); } /** * Validate if a string is a valid URL. * * @since 1.0.0 * * @param string $url URL to validate. * @return bool True if valid URL. */ private function is_valid_url( string $url ): bool { return false !== filter_var( $url, FILTER_VALIDATE_URL ); } /** * Log a resolution event if logging is enabled. * * Uses the `robotstxt_og_enable_logging` filter to toggle logging. * * @since 1.0.0 * * @param string $event Event name (e.g., 'cache_hit', 'head_request_error'). * @param array $context Additional context data. * @return void */ private function log( string $event, array $context = array() ): void { /** * Filter whether to enable debug logging for image resolution. * * @since 1.0.0 * * @param bool $enabled Whether logging is enabled. Default false. */ if ( ! apply_filters( 'robotstxt_og_enable_logging', false ) ) { return; } $message = sprintf( '[robotstxt-og] %s | %s', $event, wp_json_encode( $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( $message ); } }