PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 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 All 141 releases
← All changes | media-optimization/class-dimension-injector.php +206 -8 2.6.14 → 2.7.1 View file →
@@ -17,8 +17,23 @@
17 17
18 18 /** In-memory cache to avoid repeated file lookups within a request. */
19 19 private array $cache = [];
20 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 +
21 36 public function __construct() {
22 37 add_filter('the_content', [$this, 'inject_dimensions'], 30);
23 38 add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30);
24 39 add_filter('widget_text', [$this, 'inject_dimensions'], 30);
@@ -27,9 +42,9 @@
27 42 /**
28 43 * Find <img> tags missing width or height and inject dimensions.
29 44 */
30 45 public function inject_dimensions(string $content): string {
31 - if (empty($content) || is_admin()) {
46 + if (empty($content) || is_admin() || is_feed()) {
32 47 return $content;
33 48 }
34 49
35 50 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
@@ -67,12 +82,20 @@
67 82 return null;
68 83 }
69 84 $src = $m[1];
70 85
86 + // Request-scoped cache: avoids repeated work within a single render.
71 87 if (isset($this->cache[$src])) {
72 88 return $this->cache[$src];
73 89 }
74 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 +
75 98 $dims = null;
76 99
77 100 // Strategy 1: Try to find WordPress attachment by URL
78 101 $dims = $this->get_dims_from_attachment($src);
@@ -88,8 +111,9 @@
88 111 }
89 112
90 113 if ($dims) {
91 114 $this->cache[$src] = $dims;
115 + $this->set_cached_dimensions($src, $dims);
92 116 }
93 117
94 118 return $dims;
95 119 }
@@ -94,8 +118,117 @@
94 118 return $dims;
95 119 }
96 120
97 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 + /**
98 231 * Look up dimensions from WP attachment metadata.
99 232 */
100 233 private function get_dims_from_attachment(string $url): ?array {
101 234 $attachment_id = attachment_url_to_postid($url);
@@ -127,8 +260,17 @@
127 260 }
128 261 }
129 262 }
130 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 +
131 273 // Fall back to full size
132 274 if (!empty($meta['width']) && !empty($meta['height'])) {
133 275 return [
134 276 'width' => (int) $meta['width'],
@@ -166,25 +308,39 @@
166 308
167 309 /**
168 310 * Fetch dimensions from a remote URL.
169 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.
170 319 */
171 320 private function get_dims_from_remote(string $url): ?array {
172 - if (strpos($url, 'http') !== 0) {
321 + if (!$this->is_remote_fetch_allowed($url)) {
173 322 return null;
174 323 }
175 324
325 + $fail_key = self::FAIL_PREFIX . md5($url);
326 + if (get_transient($fail_key) !== false) {
327 + return null;
328 + }
329 +
176 330 $response = wp_remote_get($url, [
177 - 'timeout' => 3,
178 - 'headers' => ['Range' => 'bytes=0-32767'],
331 + 'timeout' => 3,
332 + 'headers' => ['Range' => 'bytes=0-' . (self::REMOTE_MAX_BYTES - 1)],
333 + 'limit_response_size' => self::REMOTE_MAX_BYTES,
179 334 ]);
180 335
181 - if (is_wp_error($response)) {
182 - return null;
336 + $body = null;
337 + if (!is_wp_error($response)) {
338 + $body = wp_remote_retrieve_body($response);
183 339 }
184 340
185 - $body = wp_remote_retrieve_body($response);
186 341 if (empty($body)) {
342 + set_transient($fail_key, 1, self::FAIL_TTL);
187 343 return null;
188 344 }
189 345
190 346 if (!function_exists('wp_tempnam')) {
@@ -193,15 +349,57 @@
193 349
194 350 $tmp = wp_tempnam($url);
195 351 file_put_contents($tmp, $body);
196 352 $info = @getimagesize($tmp);
197 - @unlink($tmp);
353 + @wp_delete_file($tmp);
198 354
199 355 if ($info && $info[0] > 0 && $info[1] > 0) {
200 356 return ['width' => $info[0], 'height' => $info[1]];
201 357 }
202 358
359 + set_transient($fail_key, 1, self::FAIL_TTL);
203 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;
204 402 }
205 403
206 404 /**
207 405 * Insert an attribute into an <img> tag.