tags missing width/height and injects them * based on file metadata to prevent Cumulative Layout Shift (CLS). * * @package Search Atlas SEO * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com * @since 2.6.0 */ if (!defined('ABSPATH')) { exit; } class Metasync_Dimension_Injector { /** In-memory cache to avoid repeated file lookups within a request. */ private array $cache = []; /** Transient key prefix for persisted image dimensions. */ private const CACHE_PREFIX = 'ms_dim_'; /** How long resolved dimensions stay cached, in seconds (7 days). */ private const CACHE_TTL = 604800; /** Transient key prefix for failed remote lookups (negative cache). */ private const FAIL_PREFIX = 'ms_dimfail_'; /** How long a failed remote lookup stays cached, in seconds (1 hour). */ private const FAIL_TTL = 3600; /** Cap on bytes fetched from a remote image header sniff. */ private const REMOTE_MAX_BYTES = 65536; public function __construct() { add_filter('the_content', [$this, 'inject_dimensions'], 30); add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30); add_filter('widget_text', [$this, 'inject_dimensions'], 30); } /** * Find tags missing width or height and inject dimensions. */ public function inject_dimensions(string $content): string { if (empty($content) || is_admin() || is_feed()) { return $content; } return preg_replace_callback('/]+>/i', function ($matches) { $tag = $matches[0]; $has_width = preg_match('/\swidth\s*=/i', $tag); $has_height = preg_match('/\sheight\s*=/i', $tag); // Both already present - nothing to do if ($has_width && $has_height) { return $tag; } $dims = $this->get_dimensions($tag); if (!$dims) { return $tag; } if (!$has_width) { $tag = $this->add_attribute($tag, 'width', (string) $dims['width']); } if (!$has_height) { $tag = $this->add_attribute($tag, 'height', (string) $dims['height']); } return $tag; }, $content); } /** * Try to determine image dimensions from multiple sources. */ private function get_dimensions(string $img_tag): ?array { if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $m)) { return null; } $src = $m[1]; // Request-scoped cache: avoids repeated work within a single render. if (isset($this->cache[$src])) { return $this->cache[$src]; } // Persistent cache: skips disk I/O for images resolved on a prior request. $cached = $this->get_cached_dimensions($src); if ($cached !== null) { $this->cache[$src] = $cached; return $cached; } $dims = null; // Strategy 1: Try to find WordPress attachment by URL $dims = $this->get_dims_from_attachment($src); // Strategy 2: Try to read the local file directly if (!$dims) { $dims = $this->get_dims_from_local_file($src); } // Strategy 3: For external images, try getimagesize with URL (slower) if (!$dims) { $dims = $this->get_dims_from_remote($src); } if ($dims) { $this->cache[$src] = $dims; $this->set_cached_dimensions($src, $dims); } return $dims; } /** * Build the transient key for a given image src. */ private static function cache_key(string $src): string { return self::CACHE_PREFIX . md5($src); } /** * Read previously resolved dimensions from the persistent transient cache. * Returns null on a cache miss or a malformed/partial cached value. */ private function get_cached_dimensions(string $src): ?array { $cached = get_transient(self::cache_key($src)); if (is_array($cached) && isset($cached['width'], $cached['height']) && (int) $cached['width'] > 0 && (int) $cached['height'] > 0 ) { return [ 'width' => (int) $cached['width'], 'height' => (int) $cached['height'], ]; } return null; } /** * Persist resolved dimensions so subsequent requests avoid file I/O. */ private function set_cached_dimensions(string $src, array $dims): void { set_transient( self::cache_key($src), [ 'width' => (int) $dims['width'], 'height' => (int) $dims['height'], ], self::CACHE_TTL ); } /** * Purge cached dimensions for every size URL of an attachment. * * Hooked on `delete_attachment` and `wp_update_attachment_metadata` so the * cache can never outlive (or contradict) the image: deleting an image and * re-uploading a different one under the same filename, or regenerating * thumbnails to new dimensions, would otherwise serve stale width/height * for up to CACHE_TTL. Registered unconditionally (even when dimension * injection is disabled) so stale entries are always cleaned up. * * @param int $attachment_id Attachment whose cached dimensions to clear. */ public static function purge_attachment_cache($attachment_id): void { $attachment_id = (int) $attachment_id; if ($attachment_id <= 0) { return; } foreach (self::collect_attachment_urls($attachment_id) as $url) { delete_transient(self::cache_key($url)); } } /** * `wp_update_attachment_metadata` filter wrapper: purge the cache, then * return the metadata untouched so the filter chain is unaffected. * * @param mixed $data Attachment metadata being saved. * @param int $attachment_id Attachment ID. * @return mixed The unmodified $data. */ public static function purge_on_metadata_update($data, $attachment_id) { self::purge_attachment_cache($attachment_id); return $data; } /** * Collect the full-size URL plus every registered sub-size URL for an * attachment. Sub-size files live in the same directory as the full-size * file, so each is the full URL with its basename swapped for the size * filename. * * @param int $attachment_id Attachment ID. * @return string[] Image URLs (possibly empty). */ private static function collect_attachment_urls(int $attachment_id): array { $full = wp_get_attachment_url($attachment_id); if (!is_string($full) || $full === '') { return []; } $urls = [$full]; $meta = wp_get_attachment_metadata($attachment_id); if (is_array($meta) && !empty($meta['sizes'])) { $slash = strrpos($full, '/'); $base = $slash === false ? '' : substr($full, 0, $slash + 1); foreach ($meta['sizes'] as $size_data) { if (!empty($size_data['file'])) { $urls[] = $base . $size_data['file']; } } } return $urls; } /** * Look up dimensions from WP attachment metadata. */ private function get_dims_from_attachment(string $url): ?array { $attachment_id = attachment_url_to_postid($url); if (!$attachment_id) { // Try without size suffix (e.g., image-300x200.jpg -> image.jpg) $clean_url = preg_replace('/-\d+x\d+(\.\w+)$/', '$1', $url); $attachment_id = attachment_url_to_postid($clean_url); } if (!$attachment_id) { return null; } $meta = wp_get_attachment_metadata($attachment_id); if (!$meta) { return null; } // Check sub-sizes first if (!empty($meta['sizes'])) { $filename = wp_basename($url); foreach ($meta['sizes'] as $size_data) { if ($size_data['file'] === $filename) { return [ 'width' => (int) $size_data['width'], 'height' => (int) $size_data['height'], ]; } } } // A sized variant with no registered sub-size (crop added after // upload, third-party sizes) must not inherit the FULL-size // dimensions — wrong width/height re-introduces the very layout // shift this module exists to prevent. Leaving the tag alone is // safer than injecting wrong values. if (preg_match('/-\d+x\d+(?=\.\w+$)/', wp_basename($url))) { return null; } // Fall back to full size if (!empty($meta['width']) && !empty($meta['height'])) { return [ 'width' => (int) $meta['width'], 'height' => (int) $meta['height'], ]; } return null; } /** * Convert URL to local path and use getimagesize(). */ private function get_dims_from_local_file(string $url): ?array { $upload_dir = wp_get_upload_dir(); if (strpos($url, $upload_dir['baseurl']) === false) { return null; } $relative = str_replace($upload_dir['baseurl'], '', $url); $file = $upload_dir['basedir'] . $relative; if (!file_exists($file)) { return null; } $info = @getimagesize($file); if ($info && $info[0] > 0 && $info[1] > 0) { return ['width' => $info[0], 'height' => $info[1]]; } return null; } /** * Fetch dimensions from a remote URL. * Downloads just enough bytes to read image headers. * * Server-side fetching of arbitrary values is an SSRF vector: * content authors can plant internal, localhost, or cloud-metadata URLs * that this server would otherwise probe on every pageview. Remote * fetching is therefore opt-in — it only runs for hosts allowlisted via * the 'metasync_dimension_remote_hosts' filter — and failures are * negatively cached so they are not re-fetched on every request. */ private function get_dims_from_remote(string $url): ?array { if (!$this->is_remote_fetch_allowed($url)) { return null; } $fail_key = self::FAIL_PREFIX . md5($url); if (get_transient($fail_key) !== false) { return null; } $response = wp_remote_get($url, [ 'timeout' => 3, 'headers' => ['Range' => 'bytes=0-' . (self::REMOTE_MAX_BYTES - 1)], 'limit_response_size' => self::REMOTE_MAX_BYTES, ]); $body = null; if (!is_wp_error($response)) { $body = wp_remote_retrieve_body($response); } if (empty($body)) { set_transient($fail_key, 1, self::FAIL_TTL); return null; } if (!function_exists('wp_tempnam')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $tmp = wp_tempnam($url); file_put_contents($tmp, $body); $info = @getimagesize($tmp); @unlink($tmp); if ($info && $info[0] > 0 && $info[1] > 0) { return ['width' => $info[0], 'height' => $info[1]]; } set_transient($fail_key, 1, self::FAIL_TTL); return null; } /** * A remote URL may only be fetched when explicitly allowed. * * @param string $url Absolute image URL. * @return bool True when the scheme is http(s), the URL passes core's * internal-address validation, and its host is allowlisted. */ private function is_remote_fetch_allowed(string $url): bool { /** * Hosts whose images may be fetched server-side for dimension * sniffing. Empty by default — remote dimension fetching is opt-in, * which closes the SSRF surface until a site owner explicitly * trusts specific hosts. Checked before any URL parsing so the * default (fetch-disabled) path stays cheap. * * @param string[] $allowed_hosts List of hostnames. */ $allowed_hosts = apply_filters('metasync_dimension_remote_hosts', []); if (empty($allowed_hosts)) { return false; } $scheme = wp_parse_url($url, PHP_URL_SCHEME); if (!in_array($scheme, ['http', 'https'], true)) { return false; } if (!wp_http_validate_url($url)) { return false; } $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); foreach ($allowed_hosts as $allowed) { if (strtolower((string) $allowed) === $host) { return true; } } return false; } /** * Insert an attribute into an tag. */ private function add_attribute(string $tag, string $name, string $value): string { return preg_replace( '/(