| @@ -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 | } |