PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.24
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.24
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
metasync / media-optimization / class-dimension-injector.php

class-dimension-injector.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.24, at media-optimization/class-dimension-injector.php

416 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Dimension_Injector
4 * Scans HTML for <img> tags missing width/height and injects them
5 * based on file metadata to prevent Cumulative Layout Shift (CLS).
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Dimension_Injector {
17
18 /** In-memory cache to avoid repeated file lookups within a request. */
19 private array $cache = [];
20
21 /** Transient key prefix for persisted image dimensions. */
22 private const CACHE_PREFIX = 'ms_dim_';
23
24 /** How long resolved dimensions stay cached, in seconds (7 days). */
25 private const CACHE_TTL = 604800;
26
27 /** Transient key prefix for failed remote lookups (negative cache). */
28 private const FAIL_PREFIX = 'ms_dimfail_';
29
30 /** How long a failed remote lookup stays cached, in seconds (1 hour). */
31 private const FAIL_TTL = 3600;
32
33 /** Cap on bytes fetched from a remote image header sniff. */
34 private const REMOTE_MAX_BYTES = 65536;
35
36 public function __construct() {
37 add_filter('the_content', [$this, 'inject_dimensions'], 30);
38 add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30);
39 add_filter('widget_text', [$this, 'inject_dimensions'], 30);
40 }
41
42 /**
43 * Find <img> tags missing width or height and inject dimensions.
44 */
45 public function inject_dimensions(string $content): string {
46 if (empty($content) || is_admin() || is_feed()) {
47 return $content;
48 }
49
50 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
51 $tag = $matches[0];
52
53 $has_width = preg_match('/\swidth\s*=/i', $tag);
54 $has_height = preg_match('/\sheight\s*=/i', $tag);
55
56 // Both already present - nothing to do
57 if ($has_width && $has_height) {
58 return $tag;
59 }
60
61 $dims = $this->get_dimensions($tag);
62 if (!$dims) {
63 return $tag;
64 }
65
66 if (!$has_width) {
67 $tag = $this->add_attribute($tag, 'width', (string) $dims['width']);
68 }
69 if (!$has_height) {
70 $tag = $this->add_attribute($tag, 'height', (string) $dims['height']);
71 }
72
73 return $tag;
74 }, $content);
75 }
76
77 /**
78 * Try to determine image dimensions from multiple sources.
79 */
80 private function get_dimensions(string $img_tag): ?array {
81 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $m)) {
82 return null;
83 }
84 $src = $m[1];
85
86 // Request-scoped cache: avoids repeated work within a single render.
87 if (isset($this->cache[$src])) {
88 return $this->cache[$src];
89 }
90
91 // Persistent cache: skips disk I/O for images resolved on a prior request.
92 $cached = $this->get_cached_dimensions($src);
93 if ($cached !== null) {
94 $this->cache[$src] = $cached;
95 return $cached;
96 }
97
98 $dims = null;
99
100 // Strategy 1: Try to find WordPress attachment by URL
101 $dims = $this->get_dims_from_attachment($src);
102
103 // Strategy 2: Try to read the local file directly
104 if (!$dims) {
105 $dims = $this->get_dims_from_local_file($src);
106 }
107
108 // Strategy 3: For external images, try getimagesize with URL (slower)
109 if (!$dims) {
110 $dims = $this->get_dims_from_remote($src);
111 }
112
113 if ($dims) {
114 $this->cache[$src] = $dims;
115 $this->set_cached_dimensions($src, $dims);
116 }
117
118 return $dims;
119 }
120
121 /**
122 * Build the transient key for a given image src.
123 */
124 private static function cache_key(string $src): string {
125 return self::CACHE_PREFIX . md5($src);
126 }
127
128 /**
129 * Read previously resolved dimensions from the persistent transient cache.
130 * Returns null on a cache miss or a malformed/partial cached value.
131 */
132 private function get_cached_dimensions(string $src): ?array {
133 $cached = get_transient(self::cache_key($src));
134
135 if (is_array($cached)
136 && isset($cached['width'], $cached['height'])
137 && (int) $cached['width'] > 0
138 && (int) $cached['height'] > 0
139 ) {
140 return [
141 'width' => (int) $cached['width'],
142 'height' => (int) $cached['height'],
143 ];
144 }
145
146 return null;
147 }
148
149 /**
150 * Persist resolved dimensions so subsequent requests avoid file I/O.
151 */
152 private function set_cached_dimensions(string $src, array $dims): void {
153 set_transient(
154 self::cache_key($src),
155 [
156 'width' => (int) $dims['width'],
157 'height' => (int) $dims['height'],
158 ],
159 self::CACHE_TTL
160 );
161 }
162
163 /**
164 * Purge cached dimensions for every size URL of an attachment.
165 *
166 * Hooked on `delete_attachment` and `wp_update_attachment_metadata` so the
167 * cache can never outlive (or contradict) the image: deleting an image and
168 * re-uploading a different one under the same filename, or regenerating
169 * thumbnails to new dimensions, would otherwise serve stale width/height
170 * for up to CACHE_TTL. Registered unconditionally (even when dimension
171 * injection is disabled) so stale entries are always cleaned up.
172 *
173 * @param int $attachment_id Attachment whose cached dimensions to clear.
174 */
175 public static function purge_attachment_cache($attachment_id): void {
176 $attachment_id = (int) $attachment_id;
177 if ($attachment_id <= 0) {
178 return;
179 }
180
181 foreach (self::collect_attachment_urls($attachment_id) as $url) {
182 delete_transient(self::cache_key($url));
183 }
184 }
185
186 /**
187 * `wp_update_attachment_metadata` filter wrapper: purge the cache, then
188 * return the metadata untouched so the filter chain is unaffected.
189 *
190 * @param mixed $data Attachment metadata being saved.
191 * @param int $attachment_id Attachment ID.
192 * @return mixed The unmodified $data.
193 */
194 public static function purge_on_metadata_update($data, $attachment_id) {
195 self::purge_attachment_cache($attachment_id);
196 return $data;
197 }
198
199 /**
200 * Collect the full-size URL plus every registered sub-size URL for an
201 * attachment. Sub-size files live in the same directory as the full-size
202 * file, so each is the full URL with its basename swapped for the size
203 * filename.
204 *
205 * @param int $attachment_id Attachment ID.
206 * @return string[] Image URLs (possibly empty).
207 */
208 private static function collect_attachment_urls(int $attachment_id): array {
209 $full = wp_get_attachment_url($attachment_id);
210 if (!is_string($full) || $full === '') {
211 return [];
212 }
213
214 $urls = [$full];
215
216 $meta = wp_get_attachment_metadata($attachment_id);
217 if (is_array($meta) && !empty($meta['sizes'])) {
218 $slash = strrpos($full, '/');
219 $base = $slash === false ? '' : substr($full, 0, $slash + 1);
220 foreach ($meta['sizes'] as $size_data) {
221 if (!empty($size_data['file'])) {
222 $urls[] = $base . $size_data['file'];
223 }
224 }
225 }
226
227 return $urls;
228 }
229
230 /**
231 * Look up dimensions from WP attachment metadata.
232 */
233 private function get_dims_from_attachment(string $url): ?array {
234 $attachment_id = attachment_url_to_postid($url);
235
236 if (!$attachment_id) {
237 // Try without size suffix (e.g., image-300x200.jpg -> image.jpg)
238 $clean_url = preg_replace('/-\d+x\d+(\.\w+)$/', '$1', $url);
239 $attachment_id = attachment_url_to_postid($clean_url);
240 }
241
242 if (!$attachment_id) {
243 return null;
244 }
245
246 $meta = wp_get_attachment_metadata($attachment_id);
247 if (!$meta) {
248 return null;
249 }
250
251 // Check sub-sizes first
252 if (!empty($meta['sizes'])) {
253 $filename = wp_basename($url);
254 foreach ($meta['sizes'] as $size_data) {
255 if ($size_data['file'] === $filename) {
256 return [
257 'width' => (int) $size_data['width'],
258 'height' => (int) $size_data['height'],
259 ];
260 }
261 }
262 }
263
264 // A sized variant with no registered sub-size (crop added after
265 // upload, third-party sizes) must not inherit the FULL-size
266 // dimensions — wrong width/height re-introduces the very layout
267 // shift this module exists to prevent. Leaving the tag alone is
268 // safer than injecting wrong values.
269 if (preg_match('/-\d+x\d+(?=\.\w+$)/', wp_basename($url))) {
270 return null;
271 }
272
273 // Fall back to full size
274 if (!empty($meta['width']) && !empty($meta['height'])) {
275 return [
276 'width' => (int) $meta['width'],
277 'height' => (int) $meta['height'],
278 ];
279 }
280
281 return null;
282 }
283
284 /**
285 * Convert URL to local path and use getimagesize().
286 */
287 private function get_dims_from_local_file(string $url): ?array {
288 $upload_dir = wp_get_upload_dir();
289
290 if (strpos($url, $upload_dir['baseurl']) === false) {
291 return null;
292 }
293
294 $relative = str_replace($upload_dir['baseurl'], '', $url);
295 $file = $upload_dir['basedir'] . $relative;
296
297 if (!file_exists($file)) {
298 return null;
299 }
300
301 $info = @getimagesize($file);
302 if ($info && $info[0] > 0 && $info[1] > 0) {
303 return ['width' => $info[0], 'height' => $info[1]];
304 }
305
306 return null;
307 }
308
309 /**
310 * Fetch dimensions from a remote URL.
311 * Downloads just enough bytes to read image headers.
312 *
313 * Server-side fetching of arbitrary <img src> values is an SSRF vector:
314 * content authors can plant internal, localhost, or cloud-metadata URLs
315 * that this server would otherwise probe on every pageview. Remote
316 * fetching is therefore opt-in — it only runs for hosts allowlisted via
317 * the 'metasync_dimension_remote_hosts' filter — and failures are
318 * negatively cached so they are not re-fetched on every request.
319 */
320 private function get_dims_from_remote(string $url): ?array {
321 if (!$this->is_remote_fetch_allowed($url)) {
322 return null;
323 }
324
325 $fail_key = self::FAIL_PREFIX . md5($url);
326 if (get_transient($fail_key) !== false) {
327 return null;
328 }
329
330 $response = wp_remote_get($url, [
331 'timeout' => 3,
332 'headers' => ['Range' => 'bytes=0-' . (self::REMOTE_MAX_BYTES - 1)],
333 'limit_response_size' => self::REMOTE_MAX_BYTES,
334 ]);
335
336 $body = null;
337 if (!is_wp_error($response)) {
338 $body = wp_remote_retrieve_body($response);
339 }
340
341 if (empty($body)) {
342 set_transient($fail_key, 1, self::FAIL_TTL);
343 return null;
344 }
345
346 if (!function_exists('wp_tempnam')) {
347 require_once ABSPATH . 'wp-admin/includes/file.php';
348 }
349
350 $tmp = wp_tempnam($url);
351 file_put_contents($tmp, $body);
352 $info = @getimagesize($tmp);
353 @unlink($tmp);
354
355 if ($info && $info[0] > 0 && $info[1] > 0) {
356 return ['width' => $info[0], 'height' => $info[1]];
357 }
358
359 set_transient($fail_key, 1, self::FAIL_TTL);
360 return null;
361 }
362
363 /**
364 * A remote URL may only be fetched when explicitly allowed.
365 *
366 * @param string $url Absolute image URL.
367 * @return bool True when the scheme is http(s), the URL passes core's
368 * internal-address validation, and its host is allowlisted.
369 */
370 private function is_remote_fetch_allowed(string $url): bool {
371 /**
372 * Hosts whose images may be fetched server-side for dimension
373 * sniffing. Empty by default — remote dimension fetching is opt-in,
374 * which closes the SSRF surface until a site owner explicitly
375 * trusts specific hosts. Checked before any URL parsing so the
376 * default (fetch-disabled) path stays cheap.
377 *
378 * @param string[] $allowed_hosts List of hostnames.
379 */
380 $allowed_hosts = apply_filters('metasync_dimension_remote_hosts', []);
381 if (empty($allowed_hosts)) {
382 return false;
383 }
384
385 $scheme = wp_parse_url($url, PHP_URL_SCHEME);
386 if (!in_array($scheme, ['http', 'https'], true)) {
387 return false;
388 }
389
390 if (!wp_http_validate_url($url)) {
391 return false;
392 }
393
394 $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
395 foreach ($allowed_hosts as $allowed) {
396 if (strtolower((string) $allowed) === $host) {
397 return true;
398 }
399 }
400
401 return false;
402 }
403
404 /**
405 * Insert an attribute into an <img> tag.
406 */
407 private function add_attribute(string $tag, string $name, string $value): string {
408 return preg_replace(
409 '/(<img\s)/i',
410 sprintf('$1%s="%s" ', esc_attr($name), esc_attr($value)),
411 $tag,
412 1
413 );
414 }
415 }
416