PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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-image-converter.php +390 -95 2.6.17 → 2.7.2 View file →
@@ -51,8 +51,40 @@
51 51 */
52 52 private static float $timer_started_at = 0.0;
53 53
54 54 /**
55 + * While a batch run is in flight, per-image cache purges are suppressed
56 + * (a thousand-image batch would otherwise trigger a thousand full-site
57 + * purges) and a single purge fires once the batch finishes. See
58 + * set_cache_purge_suppressed().
59 + */
60 + private static bool $cache_purge_suppressed = false;
61 +
62 + /**
63 + * Strip server-identifying absolute paths from log messages, keeping the
64 + * upload-relative portion that actually identifies the file.
65 + */
66 + private static function redact_path(string $path): string {
67 + $replacements = [];
68 +
69 + $upload_dir = wp_get_upload_dir();
70 + if (!empty($upload_dir['basedir'])) {
71 + $replacements[] = [$upload_dir['basedir'] . '/', 'uploads/'];
72 + }
73 + if (defined('ABSPATH')) {
74 + $replacements[] = [ABSPATH, ''];
75 + }
76 +
77 + foreach ($replacements as [$needle, $replacement]) {
78 + if (strpos($path, $needle) === 0) {
79 + return $replacement . substr($path, strlen($needle));
80 + }
81 + }
82 +
83 + return basename($path);
84 + }
85 +
86 + /**
55 87 * Get file extension for a given format.
56 88 */
57 89 private static function get_format_extension(string $format): string {
58 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
@@ -57,8 +89,43 @@
57 89 private static function get_format_extension(string $format): string {
58 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
59 91 }
60 92
93 + /**
94 + * Suppress (or re-enable) the per-image page-cache purge. The batch
95 + * optimizer suppresses around its ticks and purges once at completion.
96 + */
97 + public static function set_cache_purge_suppressed(bool $suppressed): void {
98 + self::$cache_purge_suppressed = $suppressed;
99 + }
100 +
101 + /**
102 + * Purge page caches after an image's on-disk filenames changed (replace
103 + * conversion or revert), so cached/edge pages cannot keep serving
104 + * references to deleted files.
105 + */
106 + public static function purge_page_caches(): void {
107 + if (self::$cache_purge_suppressed) {
108 + return;
109 + }
110 +
111 + // Indirect guard so static analysis cannot narrow the class name and
112 + // flag the check as redundant — the purger is plugin-loaded, but the
113 + // converter can be exercised standalone (tests, tooling).
114 + if (!self::class_is_available('Metasync_Cache_Purge')) {
115 + return;
116 + }
117 +
118 + Metasync_Cache_Purge::purge_all('media_optimization');
119 + }
120 +
121 + /**
122 + * @param string $class Class name to check.
123 + */
124 + private static function class_is_available(string $class): bool {
125 + return class_exists($class);
126 + }
127 +
61 128 public function __construct(array $settings) {
62 129 $this->settings = $settings;
63 130
64 131 // Fires after WP generates all thumbnail sizes
@@ -64,9 +131,9 @@
64 131 // Fires after WP generates all thumbnail sizes
65 132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
66 133
67 134 // If "alongside" strategy, rewrite <img> tags to <picture>
68 - if ($settings['conversion_strategy'] === 'alongside') {
135 + if (($settings['conversion_strategy'] ?? '') === 'alongside') {
69 136 // Core WordPress
70 137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
71 138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
72 139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
@@ -150,8 +217,18 @@
150 217 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
151 218 return false;
152 219 }
153 220
221 + // The exclusion list must gate batch/bulk/single-image conversions
222 + // too, not just uploads — otherwise a bulk run converts files the
223 + // site owner explicitly asked the module to leave alone. Both the
224 + // stored path and the attachment URL are matched because exclusion
225 + // entries may be written as either.
226 + $exclusion_check = new self($settings);
227 + if ($exclusion_check->is_excluded($file) || $exclusion_check->is_url_excluded((string) wp_get_attachment_url($attachment_id))) {
228 + return false;
229 + }
230 +
154 231 // Request WordPress's image processing memory limit (typically 256MB) before heavy work
155 232 wp_raise_memory_limit('image');
156 233
157 234 // Give a heavy multi-size conversion a fresh execution budget.
@@ -195,11 +272,15 @@
195 272 }
196 273
197 274 if ($strategy === 'replace') {
198 275 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
276 + // The original files are gone from disk now — cached pages still
277 + // referencing them would serve broken images until their TTL.
278 + self::purge_page_caches();
199 279 }
200 280
201 281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
202 283 return true;
203 284 }
204 285
205 286 /**
@@ -231,8 +312,15 @@
231 312 if (!$format) {
232 313 return false;
233 314 }
234 315
316 + // Replace strategy has no original to restore — the attached file IS the
317 + // converted file, so the extension swap below would leave the path
318 + // unchanged and unlink the attachment's only copy. Refuse instead.
319 + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
320 + return false;
321 + }
322 +
235 323 $file = get_attached_file($attachment_id);
236 324 if (!$file) {
237 325 return false;
238 326 }
@@ -244,11 +332,14 @@
244 332
245 333 $ext = self::get_format_extension($format);
246 334
247 335 // Delete converted full-size file
336 + // `$converted_path !== $file` guards against deleting the attachment's
337 + // own file when the extension pattern doesn't match (e.g. the attached file is
338 + // already .webp/.avif and preg_replace returns the path unchanged).
248 339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
249 - if ($converted_path && file_exists($converted_path)) {
250 - @unlink($converted_path);
340 + if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
341 + @wp_delete_file($converted_path);
251 342 }
252 343
253 344 // Delete converted sub-sizes
254 345 $metadata = wp_get_attachment_metadata($attachment_id);
@@ -254,11 +345,12 @@
254 345 $metadata = wp_get_attachment_metadata($attachment_id);
255 346 if ($metadata && !empty($metadata['sizes'])) {
256 347 $upload_dir = dirname($file);
257 348 foreach ($metadata['sizes'] as $size_data) {
258 - $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
259 - if ($size_converted && file_exists($size_converted)) {
260 - @unlink($size_converted);
349 + $size_path = $upload_dir . '/' . $size_data['file'];
350 + $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $size_path);
351 + if ($size_converted && $size_converted !== $size_path && file_exists($size_converted)) {
352 + @wp_delete_file($size_converted);
261 353 }
262 354 }
263 355 }
264 356
@@ -264,8 +356,13 @@
264 356
265 357 delete_post_meta($attachment_id, '_metasync_converted_format');
266 358 delete_post_meta($attachment_id, '_metasync_original_filesize');
267 359 delete_post_meta($attachment_id, '_metasync_replaced_original');
360 +
361 + // Cached pages may still contain <picture> markup referencing the
362 + // converted files just deleted — flush them.
363 + self::purge_page_caches();
364 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
268 365 return true;
269 366 }
270 367
271 368 /**
@@ -312,9 +409,9 @@
312 409 foreach ($paths as $path) {
313 410 foreach ($exts as $ext) {
314 411 $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path);
315 412 if ($converted && $converted !== $path && file_exists($converted)) {
316 - @unlink($converted);
413 + @wp_delete_file($converted);
317 414 }
318 415 }
319 416 }
320 417 }
@@ -357,9 +454,9 @@
357 454 * reset; false on hosts where set_time_limit() is disabled, in which
358 455 * case callers must rely on get_remaining_time() to bail out early.
359 456 */
360 457 protected static function reset_time_limit(): bool {
361 - if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) {
458 + if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running batch job; WordPress has no equivalent API
362 459 self::$timer_started_at = microtime(true);
363 460 return true;
364 461 }
365 462 return false;
@@ -423,9 +520,9 @@
423 520 // Cap the total synchronous sub-size pass so the
424 521 // per-iteration timer resets below cannot keep one request busy
425 522 // indefinitely.
426 523 if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) {
427 - error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name);
524 + error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
428 525 break;
429 526 }
430 527
431 528 // Refresh the execution timer between encodes; when the host
@@ -431,9 +528,9 @@
431 528 // Refresh the execution timer between encodes; when the host
432 529 // disables set_time_limit(), bail out before PHP's limit kills
433 530 // the request.
434 531 if (!static::reset_time_limit() && static::get_remaining_time() <= 0) {
435 - error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name);
532 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
436 533 break;
437 534 }
438 535
439 536 // Check available memory before each sub-size conversion
@@ -438,21 +535,36 @@
438 535
439 536 // Check available memory before each sub-size conversion
440 537 $available = static::get_available_memory();
441 538 if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
442 - error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
539 + error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
443 540 break;
444 541 }
445 542
446 543 $size_file = $upload_dir . '/' . $size_data['file'];
544 +
545 + // A sub-size that is already in the target format (e.g. a prior
546 + // replace run) maps onto itself: converting would rewrite the file
547 + // in place and the unlink below would then delete it. Skip.
548 + $size_ext = self::get_format_extension($format);
549 + $size_dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $size_ext, $size_file);
550 + if ($size_dest === $size_file) {
551 + continue;
552 + }
553 +
447 554 $size_converted = static::do_convert_file($size_file, $format, $quality);
448 555
449 - if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
450 - @unlink($size_file);
556 + if ($size_converted && $size_converted !== $size_file && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
557 + // Rewrite content references BEFORE unlinking — galleries,
558 + // widgets and hardcoded <img> tags point at sub-size URLs
559 + // (photo-1024x768.jpg), and the original is unrecoverable
560 + // once deleted.
561 + self::rewrite_content_paths($size_file, $size_converted);
562 + @wp_delete_file($size_file);
451 563 $size_data['file'] = basename($size_converted);
452 564 $size_data['mime-type'] = "image/{$format}";
453 565 } elseif ($size_converted && $strategy === 'replace') {
454 - error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
566 + error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . self::redact_path($size_file)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
455 567 }
456 568
457 569 // Release cyclic references between sub-size conversions
458 570 gc_collect_cycles();
@@ -501,9 +613,9 @@
501 613 return null;
502 614 }
503 615
504 616 if (filesize($source) > self::MAX_CONVERT_BYTES) {
505 - error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
617 + error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . self::redact_path($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
506 618 return null;
507 619 }
508 620
509 621 // Bail out instead of starting an encode PHP may kill mid-write
@@ -509,9 +621,9 @@
509 621 // Bail out instead of starting an encode PHP may kill mid-write
510 622 // On hosts where set_time_limit() is disabled the request
511 623 // keeps its original cap, and the fatal reported by Sentry fired here.
512 624 if (static::get_remaining_time() <= 0) {
513 - error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source));
625 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
514 626 return null;
515 627 }
516 628
517 629 // Request WordPress's image processing memory limit
@@ -529,9 +641,9 @@
529 641 $estimated = filesize($source) * 3;
530 642 }
531 643 $available = self::get_available_memory();
532 644 if ($estimated > $available * 0.8) {
533 - error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
645 + error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
534 646 return null;
535 647 }
536 648
537 649 // Determine target dimensions when the source exceeds the configured cap.
@@ -549,9 +661,9 @@
549 661 if ($result) {
550 662 return $result;
551 663 }
552 664 } catch (\Exception $e) {
553 - error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
665 + error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
554 666 }
555 667 }
556 668
557 669 if (extension_loaded('gd')) {
@@ -557,9 +669,9 @@
557 669 if (extension_loaded('gd')) {
558 670 try {
559 671 return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
560 672 } catch (\Exception $e) {
561 - error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
673 + error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
562 674 }
563 675 }
564 676
565 677 return null;
@@ -582,10 +694,10 @@
582 694 $img->stripImage();
583 695
584 696 if ($img->writeImage($dest)) {
585 697 if (!file_exists($dest) || !filesize($dest)) {
586 - @unlink($dest);
587 - error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
698 + @wp_delete_file($dest);
699 + error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
588 700 $img->destroy();
589 701 return null;
590 702 }
591 703 $img->destroy();
@@ -651,10 +763,10 @@
651 763
652 764 imagedestroy($gd_img);
653 765
654 766 if (!$success || !file_exists($dest) || !filesize($dest)) {
655 - @unlink($dest);
656 - error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
767 + @wp_delete_file($dest);
768 + error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
657 769 return null;
658 770 }
659 771
660 772 return $dest;
@@ -664,17 +776,16 @@
664 776 * Replace original file with converted version.
665 777 */
666 778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
667 779 if (!file_exists($new_path) || !filesize($new_path)) {
668 - error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
780 + error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . self::redact_path($old_path)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
669 781 return;
670 782 }
671 783
672 - // Capture old URL before deleting so we can rewrite post content references
673 - $old_url = wp_get_attachment_url($id);
674 -
675 - @unlink($old_path);
676 -
784 + // Update the DB pointers and rewrite post content FIRST — only once
785 + // every content reference points at the new file is it safe to unlink
786 + // the original. Deleting first left a window where a failed rewrite
787 + // meant permanent 404s with no original left to fall back to.
677 788 wp_update_post([
678 789 'ID' => $id,
679 790 'post_mime_type' => "image/{$fmt}",
680 791 ]);
@@ -690,42 +801,92 @@
690 801 $meta['width'] = (int) $new_dims[0];
691 802 $meta['height'] = (int) $new_dims[1];
692 803 }
693 804
694 - // Rewrite hardcoded image URLs in post content to point to the new file
695 - $new_url = wp_get_attachment_url($id);
696 - if ($old_url && $new_url && $old_url !== $new_url) {
697 - self::rewrite_content_urls($old_url, $new_url);
805 + // Rewrite hardcoded image URLs in post content to point to the new file.
806 + // Path-based, so it matches regardless of the hostname in the stored URL.
807 + self::rewrite_content_paths($old_path, $new_path);
808 +
809 + // Content now points at the converted file — the original is redundant.
810 + @wp_delete_file($old_path);
811 + }
812 +
813 + /**
814 + * Rewrite image references in all post content from one upload file to
815 + * another.
816 + *
817 + * Works on the URL path portion (e.g. /wp-content/uploads/2026/08/photo.jpg)
818 + * derived from the absolute file paths, so rewrites are hostname-agnostic
819 + * (surviving e.g. Cloudflare tunnel rotations) and can be driven by the
820 + * file paths the converter already has — no need for the attachment URL,
821 + * which is only trustworthy before the DB pointer flips.
822 + *
823 + * References with URL-encoded special characters (e.g. "my%20photo.jpg")
824 + * never match the raw path, so the encoded variant is rewritten too when
825 + * it differs.
826 + *
827 + * @param string $old_abspath Absolute path of the file being replaced.
828 + * @param string $new_abspath Absolute path of its replacement.
829 + */
830 + private static function rewrite_content_paths(string $old_abspath, string $new_abspath): void {
831 + $old_path = self::abspath_to_url_path($old_abspath);
832 + $new_path = self::abspath_to_url_path($new_abspath);
833 +
834 + if (!$old_path || !$new_path || $old_path === $new_path) {
835 + return;
698 836 }
837 +
838 + self::rewrite_content_path_pair($old_path, $new_path);
839 +
840 + // Also rewrite URL-encoded references ("photo%20name-300x200.jpg")
841 + // when encoding changes the string.
842 + $old_encoded = implode('/', array_map('rawurlencode', explode('/', $old_path)));
843 + $new_encoded = implode('/', array_map('rawurlencode', explode('/', $new_path)));
844 + if ($old_encoded !== $old_path) {
845 + self::rewrite_content_path_pair($old_encoded, $new_encoded);
846 + }
699 847 }
700 848
701 849 /**
702 - * Rewrite image URLs in all post content that references the old file path.
703 - * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
704 - * of hostname changes (e.g. Cloudflare tunnel rotations).
850 + * Map an absolute upload file path to its URL path portion
851 + * (e.g. /wp-content/uploads/2026/08/photo.jpg). Returns null when the
852 + * file lives outside the uploads directory.
705 853 */
706 - private static function rewrite_content_urls(string $old_url, string $new_url): void {
707 - global $wpdb;
854 + private static function abspath_to_url_path(string $abspath): ?string {
855 + $upload_dir = wp_get_upload_dir();
856 + $base_path = $base_url_path = null;
708 857
709 - // Extract path portions to be hostname-agnostic
710 - $old_path = wp_parse_url($old_url, PHP_URL_PATH);
711 - $new_path = wp_parse_url($new_url, PHP_URL_PATH);
858 + if (strpos($abspath, $upload_dir['basedir']) === 0) {
859 + $base_path = $upload_dir['basedir'];
860 + $base_url_path = wp_parse_url($upload_dir['baseurl'], PHP_URL_PATH);
861 + }
712 862
713 - if (!$old_path || !$new_path || $old_path === $new_path) {
714 - return;
863 + if (!$base_path || !is_string($base_url_path) || $base_url_path === '') {
864 + return null;
715 865 }
716 866
717 - // Batch the UPDATE so a large wp_posts table is never locked by a single
718 - // unbounded REPLACE. Each batch only rewrites rows that still
719 - // contain the old path; once replaced they no longer match the LIKE, so
720 - // the loop converges. The batch ceiling is a safety net against an
721 - // unexpected non-converging loop (e.g. a DB-level error returning false).
867 + $relative = substr($abspath, strlen($base_path));
868 + return rtrim($base_url_path, '/') . '/' . ltrim($relative, '/');
869 + }
870 +
871 + /**
872 + * Batched REPLACE of one URL path for another across wp_posts.
873 + *
874 + * Batching keeps a large wp_posts table from being locked by a single
875 + * unbounded REPLACE. Each batch only rewrites rows that still contain the
876 + * old path; once replaced they no longer match the LIKE, so the loop
877 + * converges. The batch ceiling is a safety net against an unexpected
878 + * non-converging loop (e.g. a DB-level error returning false).
879 + */
880 + private static function rewrite_content_path_pair(string $old_path, string $new_path): void {
881 + global $wpdb;
882 +
722 883 $batch_size = 500;
723 884 $like = '%' . $wpdb->esc_like($old_path) . '%';
724 885 $max_batches = 100000;
725 886
726 887 for ($batch = 0; $batch < $max_batches; $batch++) {
727 - $affected = $wpdb->query($wpdb->prepare(
888 + $affected = $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- batched bulk post_content rewrite — no bulk WordPress API; chunked by LIMIT to bound lock time
728 889 "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)
729 890 WHERE post_content LIKE %s ORDER BY ID LIMIT %d",
730 891 $old_path,
731 892 $new_path,
@@ -781,48 +942,23 @@
781 942 * Output buffer callback: rewrite remaining <img> tags to <picture>.
782 943 * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
783 944 */
784 945 public function rewrite_full_html(string $html): string {
785 - if (empty($html) || stripos($html, '</html>') === false) {
946 + if (empty($html) || stripos($html, '</html>') === false || $this->is_amp_request()) {
786 947 return $html;
787 948 }
788 949
789 - // Protect blocks that must not be rewritten
790 - $protected = [];
791 - $counter = 0;
950 + // Protect blocks that must not be rewritten (shared with the
951 + // content-filter path): existing <picture>, <script> (JSON-LD holds
952 + // image URLs), and <noscript> (lazy-loading fallbacks).
953 + [$html, $protected] = $this->protect_rewritable_blocks($html);
792 954
793 - // Existing <picture> blocks (already wrapped by filter hooks)
794 - $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', function ($m) use (&$protected, &$counter) {
795 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
796 - $protected[$key] = $m[0];
797 - return $key;
798 - }, $html);
799 -
800 - // <script> blocks (JSON-LD contains image URLs)
801 - $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', function ($m) use (&$protected, &$counter) {
802 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
803 - $protected[$key] = $m[0];
804 - return $key;
805 - }, $html);
806 -
807 - // <noscript> blocks (lazy-loading fallbacks)
808 - $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', function ($m) use (&$protected, &$counter) {
809 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
810 - $protected[$key] = $m[0];
811 - return $key;
812 - }, $html);
813 -
814 955 // Rewrite remaining <img> tags
815 956 $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
816 957 return $this->maybe_wrap_img_tag($matches[0]);
817 958 }, $html);
818 959
819 - // Restore protected blocks
820 - if (!empty($protected)) {
821 - $html = strtr($html, $protected);
822 - }
823 -
824 - return $html;
960 + return $this->restore_protected_blocks($html, $protected);
825 961 }
826 962
827 963 /**
828 964 * Rewrite <img> tags to <picture> with next-gen source.
@@ -828,23 +964,79 @@
828 964 * Rewrite <img> tags to <picture> with next-gen source.
829 965 * Used by WordPress filter hooks for content fragments.
830 966 */
831 967 public function rewrite_to_picture_tags(string $content): string {
832 - if (empty($content)) {
968 + if (empty($content) || is_feed() || $this->is_amp_request()) {
833 969 return $content;
834 970 }
835 971
836 - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
837 - if (strpos($content, '<picture>') !== false) {
838 - return $content;
839 - }
972 + // Existing <picture> blocks (attributed or not) are protected per
973 + // block below; a single earlier wrap no longer disables rewriting
974 + // for the whole fragment, and <img>s inside script/noscript blocks
975 + // are never touched.
976 + [$content, $protected] = $this->protect_rewritable_blocks($content);
840 977
841 - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
978 + $content = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
842 979 return $this->maybe_wrap_img_tag($matches[0]);
843 980 }, $content);
981 +
982 + return $this->restore_protected_blocks($content, $protected);
844 983 }
845 984
846 985 /**
986 + * Extract blocks that must never be rewritten (existing <picture>,
987 + * <script>, <noscript>) into placeholders so the <img> rewrite cannot
988 + * reach inside them.
989 + *
990 + * @return array{0:string, 1:array<string, string>} Rewritten HTML and placeholder map.
991 + */
992 + private function protect_rewritable_blocks(string $html): array {
993 + $protected = [];
994 + $counter = 0;
995 + $extract = function ($m) use (&$protected, &$counter) {
996 + $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
997 + $protected[$key] = $m[0];
998 + return $key;
999 + };
1000 +
1001 + $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', $extract, $html);
1002 + $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', $extract, $html);
1003 + $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', $extract, $html);
1004 +
1005 + return [$html, $protected];
1006 + }
1007 +
1008 + /**
1009 + * Restore blocks extracted by protect_rewritable_blocks().
1010 + */
1011 + private function restore_protected_blocks(string $html, array $protected): string {
1012 + if (empty($protected)) {
1013 + return $html;
1014 + }
1015 + return strtr($html, $protected);
1016 + }
1017 +
1018 + /**
1019 + * True when the current request renders an AMP page. The AMP runtime
1020 + * validates markup, so the <picture> wrapper is skipped there. The
1021 + * helper indirection keeps static analysis from assuming the AMP
1022 + * plugin's functions always exist.
1023 + */
1024 + private function is_amp_request(): bool {
1025 + if (self::func_is_available('amp_is_request')) {
1026 + return (bool) amp_is_request();
1027 + }
1028 + if (self::func_is_available('is_amp_endpoint')) {
1029 + return (bool) is_amp_endpoint();
1030 + }
1031 + return false;
1032 + }
1033 +
1034 + private static function func_is_available(string $function): bool {
1035 + return function_exists($function);
1036 + }
1037 +
1038 + /**
847 1039 * Wrap a single <img> tag in <picture> with next-gen <source>.
848 1040 * Returns the original tag unchanged if conversion is not applicable.
849 1041 */
850 1042 private function maybe_wrap_img_tag(string $img_tag): string {
@@ -856,28 +1048,29 @@
856 1048 return $img_tag;
857 1049 }
858 1050
859 1051 $original_src = $src_match[1];
860 - $format = $this->settings['conversion_format'];
861 - $ext = self::get_format_extension($format);
862 1052
863 - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
864 -
865 - if ($converted_url === $original_src) {
1053 + // Exclusion entries may target the URL (full URL or path fragment).
1054 + if ($this->is_url_excluded($original_src)) {
866 1055 return $img_tag;
867 1056 }
868 1057
869 - $converted_path = $this->url_to_path($converted_url);
870 - if (!$converted_path || !file_exists($converted_path)) {
1058 + // Serve whichever converted file actually exists: the current format
1059 + // first, then the other one. This keeps legacy conversions alive when
1060 + // the configured format flips (webp → avif or back) instead of
1061 + // silently orphaning every previously converted image.
1062 + $resolved = $this->resolve_converted_url($original_src);
1063 + if (!$resolved) {
871 1064 return $img_tag;
872 1065 }
873 1066
1067 + [$converted_url, $format] = $resolved;
874 1068 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
875 1069
876 1070 $source_srcset = '';
877 1071 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
878 - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
879 - $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
1072 + $source_srcset = sprintf(' srcset="%s"', esc_attr($this->resolve_srcset_candidates($srcset_match[1])));
880 1073 }
881 1074
882 1075 $sizes_attr = '';
883 1076 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
@@ -893,8 +1086,79 @@
893 1086 );
894 1087 }
895 1088
896 1089 /**
1090 + * Resolve the converted counterpart of an image URL.
1091 + *
1092 + * Tries the currently configured format first, then the other format, and
1093 + * only returns a URL whose file provably exists on disk. Null means "no
1094 + * converted file for this URL" — the caller leaves the tag untouched.
1095 + *
1096 + * @return array{0:string,1:string}|null [converted URL, format] or null.
1097 + */
1098 + private function resolve_converted_url(string $url): ?array {
1099 + $current = $this->settings['conversion_format'] ?? 'webp';
1100 + $formats = [$current];
1101 + $other = $current === 'webp' ? 'avif' : 'webp';
1102 + $formats[] = $other;
1103 +
1104 + foreach ($formats as $format) {
1105 + $candidate = preg_replace(self::ORIGINAL_EXT_PATTERN, self::get_format_extension($format), $url);
1106 +
1107 + if ($candidate === null || $candidate === $url) {
1108 + continue;
1109 + }
1110 +
1111 + $candidate_path = $this->url_to_path($candidate);
1112 + if ($candidate_path && file_exists($candidate_path)) {
1113 + return [$candidate, $format];
1114 + }
1115 + }
1116 +
1117 + return null;
1118 + }
1119 +
1120 + /**
1121 + * Build the <source> srcset with per-candidate file-existence checks.
1122 + *
1123 + * A srcset whose candidates were blindly extension-swapped serves 404s
1124 + * whenever a sub-size was never converted (conversion of sub-sizes off, a
1125 + * size added after conversion, a format switch) — and browsers do not fall
1126 + * back to the <img> when a <source> candidate fails to fetch. Candidates
1127 + * without a converted file on disk keep their ORIGINAL URL instead, which
1128 + * still loads, just unoptimized. When no candidate has a converted file
1129 + * the original srcset is returned unchanged.
1130 + */
1131 + private function resolve_srcset_candidates(string $srcset): string {
1132 + $candidates = preg_split('/\s*,\s*/', trim($srcset));
1133 + if (!$candidates) {
1134 + return $srcset;
1135 + }
1136 +
1137 + $any_converted = false;
1138 + $out = [];
1139 +
1140 + foreach ($candidates as $candidate) {
1141 + // Candidate is "URL [descriptor]" — rewrite only the URL part.
1142 + if (!preg_match('/^(\S+)(.*)$/', $candidate, $parts)) {
1143 + $out[] = $candidate;
1144 + continue;
1145 + }
1146 +
1147 + $resolved = $this->resolve_converted_url($parts[1]);
1148 + if ($resolved) {
1149 + $any_converted = true;
1150 + $out[] = $resolved[0] . $parts[2];
1151 + } else {
1152 + // Keep the original URL so this descriptor still resolves.
1153 + $out[] = $candidate;
1154 + }
1155 + }
1156 +
1157 + return $any_converted ? implode(', ', $out) : $srcset;
1158 + }
1159 +
1160 + /**
897 1161 * Convert a URL to a local file path. Returns null if URL is external.
898 1162 * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
899 1163 */
900 1164 private function url_to_path(string $url): ?string {
@@ -901,8 +1165,14 @@
901 1165 $upload_dir = wp_get_upload_dir();
902 1166 $base_url = $upload_dir['baseurl'];
903 1167 $base_path = $upload_dir['basedir'];
904 1168
1169 + // Filenames with URL-encoded characters (spaces, accents, UTF-8)
1170 + // must be decoded before mapping to a disk path — the existence
1171 + // checks that gate serving silently reject them otherwise and a
1172 + // successfully converted file is never served.
1173 + $url = rawurldecode($url);
1174 +
905 1175 // Direct match (same hostname)
906 1176 if (strpos($url, $base_url) === 0) {
907 1177 return str_replace($base_url, $base_path, $url);
908 1178 }
@@ -930,8 +1200,33 @@
930 1200
931 1201 foreach ($exclude_urls as $pattern) {
932 1202 if (stripos($file, $pattern) !== false) {
933 1203 return true;
1204 + }
1205 + }
1206 + return false;
1207 + }
1208 +
1209 + /**
1210 + * Check if a URL matches the URL-based exclusion patterns.
1211 + *
1212 + * Entries may be full URLs (scheme + host) or bare path fragments; the
1213 + * full-URL form never matches the path-only check in is_excluded(), so
1214 + * both the raw URL and its path portion are tested here.
1215 + */
1216 + private function is_url_excluded(string $url): bool {
1217 + $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
1218 + if (empty($exclude_urls)) {
1219 + return false;
1220 + }
1221 +
1222 + $candidates = [$url, (string) wp_parse_url($url, PHP_URL_PATH)];
1223 +
1224 + foreach ($exclude_urls as $pattern) {
1225 + foreach ($candidates as $candidate) {
1226 + if ($candidate !== '' && stripos($candidate, $pattern) !== false) {
1227 + return true;
1228 + }
934 1229 }
935 1230 }
936 1231 return false;
937 1232 }