| @@ -24,10 +24,67 @@ | ||
| 24 | 24 | |
| 25 | 25 | private const EXT_AVIF = '.avif'; |
| 26 | 26 | private const EXT_WEBP = '.webp'; |
| 27 | 27 | private const ORIGINAL_EXT_PATTERN = '/\.(jpe?g|png)$/i'; |
| 28 | + private const MAX_CONVERT_BYTES = 10 * 1024 * 1024; | |
| 28 | 29 | |
| 29 | 30 | /** |
| 31 | + * Minimum available memory (bytes) required before attempting a sub-size conversion. | |
| 32 | + * Remaining sub-sizes are skipped when available memory drops below this threshold. | |
| 33 | + */ | |
| 34 | + private const MIN_MEMORY_FOR_SUBSIZE = 8 * 1024 * 1024; | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Fresh execution budget (seconds) granted before heavy conversion work, | |
| 38 | + * and the cap on a single synchronous sub-size pass. | |
| 39 | + */ | |
| 40 | + private const UPLOAD_TIME_LIMIT = 300; | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * Seconds reserved before PHP's hard limit, mirroring class-media-batch-optimizer.php. | |
| 44 | + */ | |
| 45 | + private const TIME_SAFETY_MARGIN = 5; | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * When the PHP execution timer last (re)started, as a microtime(true) | |
| 49 | + * timestamp. 0.0 means the timer has never been reset, so it has been | |
| 50 | + * running since the request started ($_SERVER['REQUEST_TIME_FLOAT']). | |
| 51 | + */ | |
| 52 | + private static float $timer_started_at = 0.0; | |
| 53 | + | |
| 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 | + /** | |
| 30 | 87 | * Get file extension for a given format. |
| 31 | 88 | */ |
| 32 | 89 | private static function get_format_extension(string $format): string { |
| 33 | 90 | return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP; |
| @@ -32,8 +89,43 @@ | ||
| 32 | 89 | private static function get_format_extension(string $format): string { |
| 33 | 90 | return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP; |
| 34 | 91 | } |
| 35 | 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 | + | |
| 36 | 128 | public function __construct(array $settings) { |
| 37 | 129 | $this->settings = $settings; |
| 38 | 130 | |
| 39 | 131 | // Fires after WP generates all thumbnail sizes |
| @@ -39,9 +131,9 @@ | ||
| 39 | 131 | // Fires after WP generates all thumbnail sizes |
| 40 | 132 | add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2); |
| 41 | 133 | |
| 42 | 134 | // If "alongside" strategy, rewrite <img> tags to <picture> |
| 43 | - if ($settings['conversion_strategy'] === 'alongside') { | |
| 135 | + if (($settings['conversion_strategy'] ?? '') === 'alongside') { | |
| 44 | 136 | // Core WordPress |
| 45 | 137 | add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20); |
| 46 | 138 | add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20); |
| 47 | 139 | add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20); |
| @@ -50,8 +142,11 @@ | ||
| 50 | 142 | add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20); |
| 51 | 143 | add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20); |
| 52 | 144 | add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20); |
| 53 | 145 | add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20); |
| 146 | + | |
| 147 | + // Output buffer catch-all for themes/builders bypassing WP filters (Divi, Elementor, etc.) | |
| 148 | + add_action('template_redirect', [$this, 'start_output_buffer'], 1); | |
| 54 | 149 | } |
| 55 | 150 | } |
| 56 | 151 | |
| 57 | 152 | /** |
| @@ -72,35 +167,39 @@ | ||
| 72 | 167 | |
| 73 | 168 | $format = $this->settings['conversion_format']; |
| 74 | 169 | $quality = (int) $this->settings['conversion_quality']; |
| 75 | 170 | $strategy = $this->settings['conversion_strategy']; |
| 171 | + $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0); | |
| 76 | 172 | |
| 77 | - // Convert main/full file | |
| 78 | - $converted = $this->convert_file($file, $format, $quality); | |
| 173 | + // Capture original size before any replacement deletes the source file. | |
| 174 | + $original_size = file_exists($file) ? (int) filesize($file) : 0; | |
| 79 | 175 | |
| 176 | + // Give a heavy multi-size upload a fresh execution budget instead of | |
| 177 | + // inheriting what WP's own thumbnail generation left of the request's | |
| 178 | + // default cap. | |
| 179 | + static::reset_time_limit(); | |
| 180 | + | |
| 181 | + // Convert main/full file (downscaled to $max_dim if it exceeds the limit) | |
| 182 | + $converted = $this->convert_file($file, $format, $quality, $max_dim); | |
| 183 | + | |
| 80 | 184 | if ($converted && $strategy === 'replace') { |
| 81 | 185 | $this->replace_original($attachment_id, $file, $converted, $metadata, $format); |
| 186 | + update_post_meta($attachment_id, '_metasync_replaced_original', '1'); | |
| 82 | 187 | } |
| 83 | 188 | |
| 84 | - // Convert sub-sizes (thumbnails, medium, large, etc.) | |
| 189 | + // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management | |
| 85 | 190 | if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) { |
| 86 | - $upload_dir = dirname($file); | |
| 87 | - foreach ($metadata['sizes'] as $size_name => &$size_data) { | |
| 88 | - $size_file = $upload_dir . '/' . $size_data['file']; | |
| 89 | - $size_converted = $this->convert_file($size_file, $format, $quality); | |
| 90 | - | |
| 91 | - if ($size_converted && $strategy === 'replace') { | |
| 92 | - @unlink($size_file); | |
| 93 | - $size_data['file'] = basename($size_converted); | |
| 94 | - $size_data['mime-type'] = "image/{$format}"; | |
| 95 | - } | |
| 96 | - } | |
| 97 | - unset($size_data); | |
| 191 | + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy); | |
| 98 | 192 | } |
| 99 | 193 | |
| 100 | - // Store converted format in meta for later use by picture tag rewriter | |
| 101 | - if ($converted && $strategy === 'alongside') { | |
| 194 | + // Record the conversion for BOTH strategies so the image reports as | |
| 195 | + // optimized in the library and is not re-queued by the batch optimizer. | |
| 196 | + // (The picture tag rewriter also relies on this meta for "alongside".) | |
| 197 | + if ($converted) { | |
| 102 | 198 | update_post_meta($attachment_id, '_metasync_converted_format', $format); |
| 199 | + if ($original_size) { | |
| 200 | + update_post_meta($attachment_id, '_metasync_original_filesize', $original_size); | |
| 201 | + } | |
| 103 | 202 | } |
| 104 | 203 | |
| 105 | 204 | return $metadata; |
| 106 | 205 | } |
| @@ -118,16 +217,33 @@ | ||
| 118 | 217 | if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) { |
| 119 | 218 | return false; |
| 120 | 219 | } |
| 121 | 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 | + | |
| 231 | + // Request WordPress's image processing memory limit (typically 256MB) before heavy work | |
| 232 | + wp_raise_memory_limit('image'); | |
| 233 | + | |
| 234 | + // Give a heavy multi-size conversion a fresh execution budget. | |
| 235 | + static::reset_time_limit(); | |
| 236 | + | |
| 122 | 237 | $format = $settings['conversion_format'] ?? 'webp'; |
| 123 | 238 | $quality = (int) ($settings['conversion_quality'] ?? 82); |
| 124 | 239 | $strategy = $settings['conversion_strategy'] ?? 'alongside'; |
| 240 | + $max_dim = (int) ($settings['max_image_dimensions'] ?? 0); | |
| 125 | 241 | |
| 126 | 242 | // Store original file size before conversion for savings display |
| 127 | 243 | $original_size = filesize($file); |
| 128 | 244 | |
| 129 | - $converted = self::do_convert_file($file, $format, $quality); | |
| 245 | + $converted = self::do_convert_file($file, $format, $quality, $max_dim); | |
| 130 | 246 | if (!$converted) { |
| 131 | 247 | return false; |
| 132 | 248 | } |
| 133 | 249 | |
| @@ -143,24 +259,13 @@ | ||
| 143 | 259 | wp_update_attachment_metadata($attachment_id, $metadata); |
| 144 | 260 | } |
| 145 | 261 | } |
| 146 | 262 | |
| 147 | - // Convert sub-sizes | |
| 263 | + // Convert sub-sizes with memory management | |
| 148 | 264 | if (!empty($settings['convert_existing_sizes'])) { |
| 149 | 265 | $metadata = wp_get_attachment_metadata($attachment_id); |
| 150 | 266 | if ($metadata && !empty($metadata['sizes'])) { |
| 151 | - $upload_dir = dirname($file); | |
| 152 | - foreach ($metadata['sizes'] as $size_name => &$size_data) { | |
| 153 | - $size_file = $upload_dir . '/' . $size_data['file']; | |
| 154 | - $size_converted = self::do_convert_file($size_file, $format, $quality); | |
| 155 | - | |
| 156 | - if ($size_converted && $strategy === 'replace') { | |
| 157 | - @unlink($size_file); | |
| 158 | - $size_data['file'] = basename($size_converted); | |
| 159 | - $size_data['mime-type'] = "image/{$format}"; | |
| 160 | - } | |
| 161 | - } | |
| 162 | - unset($size_data); | |
| 267 | + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy); | |
| 163 | 268 | if ($strategy === 'replace') { |
| 164 | 269 | wp_update_attachment_metadata($attachment_id, $metadata); |
| 165 | 270 | } |
| 166 | 271 | } |
| @@ -165,13 +270,41 @@ | ||
| 165 | 270 | } |
| 166 | 271 | } |
| 167 | 272 | } |
| 168 | 273 | |
| 274 | + if ($strategy === 'replace') { | |
| 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(); | |
| 279 | + } | |
| 280 | + | |
| 169 | 281 | update_post_meta($attachment_id, '_metasync_converted_format', $format); |
| 282 | + Metasync_Media_Batch_Optimizer::flush_stats_cache(); | |
| 170 | 283 | return true; |
| 171 | 284 | } |
| 172 | 285 | |
| 173 | 286 | /** |
| 287 | + * Check whether an optimized attachment can be reverted. | |
| 288 | + * Returns false when the original was replaced (no backup exists). | |
| 289 | + */ | |
| 290 | + public static function can_revert(int $attachment_id): bool { | |
| 291 | + $format = get_post_meta($attachment_id, '_metasync_converted_format', true); | |
| 292 | + if (!$format) { | |
| 293 | + return false; // Not optimized | |
| 294 | + } | |
| 295 | + | |
| 296 | + // If the original was replaced, revert is impossible | |
| 297 | + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) { | |
| 298 | + return false; | |
| 299 | + } | |
| 300 | + | |
| 301 | + // Verify original file still exists on disk (alongside strategy) | |
| 302 | + $file = get_attached_file($attachment_id); | |
| 303 | + return $file && file_exists($file); | |
| 304 | + } | |
| 305 | + | |
| 306 | + /** | |
| 174 | 307 | * Revert an attachment's conversion (alongside strategy only). |
| 175 | 308 | * Deletes the converted files and removes the meta marker. |
| 176 | 309 | */ |
| 177 | 310 | public static function revert_attachment(int $attachment_id): bool { |
| @@ -179,8 +312,15 @@ | ||
| 179 | 312 | if (!$format) { |
| 180 | 313 | return false; |
| 181 | 314 | } |
| 182 | 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 | + | |
| 183 | 323 | $file = get_attached_file($attachment_id); |
| 184 | 324 | if (!$file) { |
| 185 | 325 | return false; |
| 186 | 326 | } |
| @@ -192,11 +332,14 @@ | ||
| 192 | 332 | |
| 193 | 333 | $ext = self::get_format_extension($format); |
| 194 | 334 | |
| 195 | 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). | |
| 196 | 339 | $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file); |
| 197 | - if ($converted_path && file_exists($converted_path)) { | |
| 198 | - @unlink($converted_path); | |
| 340 | + if ($converted_path && $converted_path !== $file && file_exists($converted_path)) { | |
| 341 | + @wp_delete_file($converted_path); | |
| 199 | 342 | } |
| 200 | 343 | |
| 201 | 344 | // Delete converted sub-sizes |
| 202 | 345 | $metadata = wp_get_attachment_metadata($attachment_id); |
| @@ -202,11 +345,12 @@ | ||
| 202 | 345 | $metadata = wp_get_attachment_metadata($attachment_id); |
| 203 | 346 | if ($metadata && !empty($metadata['sizes'])) { |
| 204 | 347 | $upload_dir = dirname($file); |
| 205 | 348 | foreach ($metadata['sizes'] as $size_data) { |
| 206 | - $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']); | |
| 207 | - if ($size_converted && file_exists($size_converted)) { | |
| 208 | - @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); | |
| 209 | 353 | } |
| 210 | 354 | } |
| 211 | 355 | } |
| 212 | 356 | |
| @@ -211,21 +355,303 @@ | ||
| 211 | 355 | } |
| 212 | 356 | |
| 213 | 357 | delete_post_meta($attachment_id, '_metasync_converted_format'); |
| 214 | 358 | delete_post_meta($attachment_id, '_metasync_original_filesize'); |
| 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(); | |
| 215 | 365 | return true; |
| 216 | 366 | } |
| 217 | 367 | |
| 368 | + /** | |
| 369 | + * Delete converted sibling files when an attachment is deleted. | |
| 370 | + * | |
| 371 | + * Hooked on `delete_attachment` (fires before WordPress removes the | |
| 372 | + * attachment's own files). The "alongside" strategy writes .webp/.avif | |
| 373 | + * files next to the originals that are NOT tracked in attachment metadata, | |
| 374 | + * so WordPress core never deletes them and they leak on disk. The "replace" | |
| 375 | + * strategy's converted files ARE the attachment files and are removed by | |
| 376 | + * core, so nothing extra is needed there. | |
| 377 | + * | |
| 378 | + * @param int $attachment_id Attachment being deleted. | |
| 379 | + */ | |
| 380 | + public static function cleanup_on_delete(int $attachment_id): void { | |
| 381 | + // Replace-strategy converted files are the attachment files themselves | |
| 382 | + // (deleted by core). Only "alongside" siblings need manual cleanup. | |
| 383 | + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) { | |
| 384 | + return; | |
| 385 | + } | |
| 386 | + | |
| 387 | + $file = get_attached_file($attachment_id); | |
| 388 | + if (!$file) { | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + | |
| 392 | + // Prefer the recorded format; fall back to both next-gen extensions | |
| 393 | + // when the format is unknown (e.g. created by an older plugin version). | |
| 394 | + $format = get_post_meta($attachment_id, '_metasync_converted_format', true); | |
| 395 | + $exts = $format ? [self::get_format_extension($format)] : [self::EXT_WEBP, self::EXT_AVIF]; | |
| 396 | + | |
| 397 | + // Collect the full-size file plus every registered sub-size. | |
| 398 | + $paths = [$file]; | |
| 399 | + $metadata = wp_get_attachment_metadata($attachment_id); | |
| 400 | + if (is_array($metadata) && !empty($metadata['sizes'])) { | |
| 401 | + $dir = dirname($file); | |
| 402 | + foreach ($metadata['sizes'] as $size_data) { | |
| 403 | + if (!empty($size_data['file'])) { | |
| 404 | + $paths[] = $dir . '/' . $size_data['file']; | |
| 405 | + } | |
| 406 | + } | |
| 407 | + } | |
| 408 | + | |
| 409 | + foreach ($paths as $path) { | |
| 410 | + foreach ($exts as $ext) { | |
| 411 | + $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path); | |
| 412 | + if ($converted && $converted !== $path && file_exists($converted)) { | |
| 413 | + @wp_delete_file($converted); | |
| 414 | + } | |
| 415 | + } | |
| 416 | + } | |
| 417 | + } | |
| 418 | + | |
| 218 | 419 | // ── Core Conversion (static, reusable) ── |
| 219 | 420 | |
| 220 | 421 | /** |
| 422 | + * Get available PHP memory in bytes. | |
| 423 | + * Returns PHP_INT_MAX when no limit is set (-1) or unreadable. | |
| 424 | + * Returns 0 when memory_limit is "0" or empty. | |
| 425 | + */ | |
| 426 | + protected static function get_available_memory(): int { | |
| 427 | + $limit = ini_get('memory_limit'); | |
| 428 | + | |
| 429 | + if ($limit === false || $limit === '-1') { | |
| 430 | + return PHP_INT_MAX; | |
| 431 | + } | |
| 432 | + | |
| 433 | + $limit = trim($limit); | |
| 434 | + if ($limit === '' || $limit === '0') { | |
| 435 | + return 0; | |
| 436 | + } | |
| 437 | + | |
| 438 | + $value = (int) $limit; | |
| 439 | + $unit = strtolower(substr($limit, -1)); | |
| 440 | + | |
| 441 | + $value = match ($unit) { | |
| 442 | + 'g' => $value * 1024 * 1024 * 1024, | |
| 443 | + 'm' => $value * 1024 * 1024, | |
| 444 | + 'k' => $value * 1024, | |
| 445 | + default => $value, | |
| 446 | + }; | |
| 447 | + | |
| 448 | + return max(0, $value - memory_get_usage(true)); | |
| 449 | + } | |
| 450 | + | |
| 451 | + /** | |
| 452 | + * Grant the current request a fresh execution budget before heavy | |
| 453 | + * conversion work. Returns true when the timer was actually | |
| 454 | + * reset; false on hosts where set_time_limit() is disabled, in which | |
| 455 | + * case callers must rely on get_remaining_time() to bail out early. | |
| 456 | + */ | |
| 457 | + protected static function reset_time_limit(): bool { | |
| 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 | |
| 459 | + self::$timer_started_at = microtime(true); | |
| 460 | + return true; | |
| 461 | + } | |
| 462 | + return false; | |
| 463 | + } | |
| 464 | + | |
| 465 | + /** | |
| 466 | + * Seconds left before PHP's execution limit kills the request, minus | |
| 467 | + * TIME_SAFETY_MARGIN. | |
| 468 | + * | |
| 469 | + * Defense-in-depth for hosts where set_time_limit() is disabled: the | |
| 470 | + * elapsed time is measured from the request start | |
| 471 | + * ($_SERVER['REQUEST_TIME_FLOAT']) — not from when conversion began — so | |
| 472 | + * time WordPress already spent generating thumbnails counts against the | |
| 473 | + * budget. Once reset_time_limit() succeeds, the baseline moves to the | |
| 474 | + * moment of the reset, matching PHP's own restarted timer. | |
| 475 | + * | |
| 476 | + * On Linux max_execution_time counts CPU time while this measures wall | |
| 477 | + * clock, so the estimate only errs on the early-bail (safe) side. | |
| 478 | + * | |
| 479 | + * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability. | |
| 480 | + * @return float Remaining seconds; PHP_INT_MAX when no limit applies. | |
| 481 | + */ | |
| 482 | + protected static function get_remaining_time(int $max_execution_time = -1): float { | |
| 483 | + if ($max_execution_time < 0) { | |
| 484 | + $max_execution_time = (int) ini_get('max_execution_time'); | |
| 485 | + } | |
| 486 | + | |
| 487 | + // No execution limit (CLI, unlimited hosts): a timeout fatal is | |
| 488 | + // impossible, so never cut conversions short. | |
| 489 | + if ($max_execution_time <= 0) { | |
| 490 | + return (float) PHP_INT_MAX; | |
| 491 | + } | |
| 492 | + | |
| 493 | + $started = self::$timer_started_at > 0.0 | |
| 494 | + ? self::$timer_started_at | |
| 495 | + : (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true)); | |
| 496 | + | |
| 497 | + return $max_execution_time - (microtime(true) - $started) - self::TIME_SAFETY_MARGIN; | |
| 498 | + } | |
| 499 | + | |
| 500 | + /** | |
| 501 | + * Convert sub-sizes with memory and execution-time management. | |
| 502 | + * | |
| 503 | + * Runs gc_collect_cycles() between each sub-size to release memory and | |
| 504 | + * checks available memory before each iteration, skipping remaining | |
| 505 | + * sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE. Each | |
| 506 | + * iteration also refreshes the execution timer (or, where that is | |
| 507 | + * disabled, bails out before PHP's limit is hit) so a heavy multi-size | |
| 508 | + * image cannot trigger a max_execution_time fatal. | |
| 509 | + * | |
| 510 | + * @param array $sizes Reference to $metadata['sizes']. | |
| 511 | + * @param string $upload_dir Directory containing the sub-size files. | |
| 512 | + * @param string $format Target format (webp|avif). | |
| 513 | + * @param int $quality Compression quality. | |
| 514 | + * @param string $strategy Conversion strategy (replace|alongside). | |
| 515 | + */ | |
| 516 | + protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void { | |
| 517 | + $pass_started = microtime(true); | |
| 518 | + | |
| 519 | + foreach ($sizes as $size_name => &$size_data) { | |
| 520 | + // Cap the total synchronous sub-size pass so the | |
| 521 | + // per-iteration timer resets below cannot keep one request busy | |
| 522 | + // indefinitely. | |
| 523 | + if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) { | |
| 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 | |
| 525 | + break; | |
| 526 | + } | |
| 527 | + | |
| 528 | + // Refresh the execution timer between encodes; when the host | |
| 529 | + // disables set_time_limit(), bail out before PHP's limit kills | |
| 530 | + // the request. | |
| 531 | + if (!static::reset_time_limit() && static::get_remaining_time() <= 0) { | |
| 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 | |
| 533 | + break; | |
| 534 | + } | |
| 535 | + | |
| 536 | + // Check available memory before each sub-size conversion | |
| 537 | + $available = static::get_available_memory(); | |
| 538 | + if ($available < self::MIN_MEMORY_FOR_SUBSIZE) { | |
| 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 | |
| 540 | + break; | |
| 541 | + } | |
| 542 | + | |
| 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 | + | |
| 554 | + $size_converted = static::do_convert_file($size_file, $format, $quality); | |
| 555 | + | |
| 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); | |
| 563 | + $size_data['file'] = basename($size_converted); | |
| 564 | + $size_data['mime-type'] = "image/{$format}"; | |
| 565 | + } elseif ($size_converted && $strategy === 'replace') { | |
| 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 | |
| 567 | + } | |
| 568 | + | |
| 569 | + // Release cyclic references between sub-size conversions | |
| 570 | + gc_collect_cycles(); | |
| 571 | + } | |
| 572 | + unset($size_data); | |
| 573 | + } | |
| 574 | + | |
| 575 | + /** | |
| 576 | + * Calculate downscaled dimensions that fit within a square bound while | |
| 577 | + * preserving aspect ratio. | |
| 578 | + * | |
| 579 | + * Returns [width, height] when the image exceeds $max on either axis, or | |
| 580 | + * null when no downscale is needed (already within bounds, downscaling | |
| 581 | + * disabled, or invalid dimensions). The result is never upscaled. | |
| 582 | + * | |
| 583 | + * @param int $width Original width in pixels. | |
| 584 | + * @param int $height Original height in pixels. | |
| 585 | + * @param int $max Maximum allowed width/height; 0 disables downscaling. | |
| 586 | + * @return array{0:int,1:int}|null | |
| 587 | + */ | |
| 588 | + protected static function calc_scaled_dimensions(int $width, int $height, int $max): ?array { | |
| 589 | + if ($max <= 0 || $width <= 0 || $height <= 0) { | |
| 590 | + return null; | |
| 591 | + } | |
| 592 | + | |
| 593 | + if ($width <= $max && $height <= $max) { | |
| 594 | + return null; | |
| 595 | + } | |
| 596 | + | |
| 597 | + $ratio = min($max / $width, $max / $height); | |
| 598 | + $new_w = max(1, (int) round($width * $ratio)); | |
| 599 | + $new_h = max(1, (int) round($height * $ratio)); | |
| 600 | + | |
| 601 | + return [$new_w, $new_h]; | |
| 602 | + } | |
| 603 | + | |
| 604 | + /** | |
| 221 | 605 | * Convert a single image file. Returns path to converted file or null on failure. |
| 606 | + * | |
| 607 | + * When $max_dimensions is greater than zero and the source exceeds it on | |
| 608 | + * either axis, the image is downscaled (aspect ratio preserved) before | |
| 609 | + * encoding. This lowers the encoder's pixel buffer and shrinks output. | |
| 222 | 610 | */ |
| 223 | - protected static function do_convert_file(string $source, string $format, int $quality): ?string { | |
| 611 | + protected static function do_convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string { | |
| 224 | 612 | if (!file_exists($source)) { |
| 225 | 613 | return null; |
| 226 | 614 | } |
| 227 | 615 | |
| 616 | + if (filesize($source) > self::MAX_CONVERT_BYTES) { | |
| 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 | |
| 618 | + return null; | |
| 619 | + } | |
| 620 | + | |
| 621 | + // Bail out instead of starting an encode PHP may kill mid-write | |
| 622 | + // On hosts where set_time_limit() is disabled the request | |
| 623 | + // keeps its original cap, and the fatal reported by Sentry fired here. | |
| 624 | + if (static::get_remaining_time() <= 0) { | |
| 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 | |
| 626 | + return null; | |
| 627 | + } | |
| 628 | + | |
| 629 | + // Request WordPress's image processing memory limit | |
| 630 | + wp_raise_memory_limit('image'); | |
| 631 | + | |
| 632 | + // Pre-flight memory check using pixel dimensions when available. | |
| 633 | + // Note: the estimate intentionally uses the ORIGINAL dimensions because | |
| 634 | + // both encoders must decode the full-resolution source into memory | |
| 635 | + // before any downscale can be applied. | |
| 636 | + $info = getimagesize($source); | |
| 637 | + if ($info && $info[0] > 0 && $info[1] > 0) { | |
| 638 | + $bpp = ($info['mime'] === 'image/png') ? 4 : 3; | |
| 639 | + $estimated = (int) ($info[0] * $info[1] * $bpp * 1.8); | |
| 640 | + } else { | |
| 641 | + $estimated = filesize($source) * 3; | |
| 642 | + } | |
| 643 | + $available = self::get_available_memory(); | |
| 644 | + if ($estimated > $available * 0.8) { | |
| 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 | |
| 646 | + return null; | |
| 647 | + } | |
| 648 | + | |
| 649 | + // Determine target dimensions when the source exceeds the configured cap. | |
| 650 | + $target_dimensions = ($info && $info[0] > 0 && $info[1] > 0) | |
| 651 | + ? self::calc_scaled_dimensions((int) $info[0], (int) $info[1], $max_dimensions) | |
| 652 | + : null; | |
| 653 | + | |
| 228 | 654 | $ext = self::get_format_extension($format); |
| 229 | 655 | $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source); |
| 230 | 656 | |
| 231 | 657 | // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate) |
| @@ -230,22 +656,22 @@ | ||
| 230 | 656 | |
| 231 | 657 | // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate) |
| 232 | 658 | if (extension_loaded('imagick')) { |
| 233 | 659 | try { |
| 234 | - $result = self::do_convert_with_imagick($source, $dest, $format, $quality); | |
| 660 | + $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions); | |
| 235 | 661 | if ($result) { |
| 236 | 662 | return $result; |
| 237 | 663 | } |
| 238 | 664 | } catch (\Exception $e) { |
| 239 | - 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 | |
| 240 | 666 | } |
| 241 | 667 | } |
| 242 | 668 | |
| 243 | 669 | if (extension_loaded('gd')) { |
| 244 | 670 | try { |
| 245 | - return self::do_convert_with_gd($source, $dest, $format, $quality); | |
| 671 | + return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions); | |
| 246 | 672 | } catch (\Exception $e) { |
| 247 | - 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 | |
| 248 | 674 | } |
| 249 | 675 | } |
| 250 | 676 | |
| 251 | 677 | return null; |
| @@ -250,15 +676,31 @@ | ||
| 250 | 676 | |
| 251 | 677 | return null; |
| 252 | 678 | } |
| 253 | 679 | |
| 254 | - private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string { | |
| 255 | - $img = new \Imagick($src); | |
| 680 | + private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string { | |
| 681 | + $img = new \Imagick(); | |
| 682 | + $img->setResourceLimit(\Imagick::RESOURCETYPE_MEMORY, 64 * 1024 * 1024); | |
| 683 | + $img->setResourceLimit(\Imagick::RESOURCETYPE_MAP, 128 * 1024 * 1024); | |
| 684 | + $img->readImage($src); | |
| 685 | + | |
| 686 | + // Downscale before encoding when the image exceeds the configured cap. | |
| 687 | + if ($target_dimensions !== null) { | |
| 688 | + [$new_w, $new_h] = $target_dimensions; | |
| 689 | + $img->scaleImage($new_w, $new_h); | |
| 690 | + } | |
| 691 | + | |
| 256 | 692 | $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp'); |
| 257 | 693 | $img->setImageCompressionQuality($q); |
| 258 | 694 | $img->stripImage(); |
| 259 | 695 | |
| 260 | 696 | if ($img->writeImage($dest)) { |
| 697 | + if (!file_exists($dest) || !filesize($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 | |
| 700 | + $img->destroy(); | |
| 701 | + return null; | |
| 702 | + } | |
| 261 | 703 | $img->destroy(); |
| 262 | 704 | return $dest; |
| 263 | 705 | } |
| 264 | 706 | |
| @@ -265,14 +707,16 @@ | ||
| 265 | 707 | $img->destroy(); |
| 266 | 708 | return null; |
| 267 | 709 | } |
| 268 | 710 | |
| 269 | - private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string { | |
| 711 | + private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string { | |
| 270 | 712 | $info = getimagesize($src); |
| 271 | 713 | if (!$info) { |
| 272 | 714 | return null; |
| 273 | 715 | } |
| 274 | 716 | |
| 717 | + $is_png = ($info['mime'] === 'image/png'); | |
| 718 | + | |
| 275 | 719 | $gd_img = match ($info['mime']) { |
| 276 | 720 | 'image/jpeg' => imagecreatefromjpeg($src), |
| 277 | 721 | 'image/png' => imagecreatefrompng($src), |
| 278 | 722 | default => null, |
| @@ -281,14 +725,37 @@ | ||
| 281 | 725 | if (!$gd_img) { |
| 282 | 726 | return null; |
| 283 | 727 | } |
| 284 | 728 | |
| 285 | - if ($info['mime'] === 'image/png') { | |
| 729 | + if ($is_png) { | |
| 286 | 730 | imagepalettetotruecolor($gd_img); |
| 287 | 731 | imagealphablending($gd_img, true); |
| 288 | 732 | imagesavealpha($gd_img, true); |
| 289 | 733 | } |
| 290 | 734 | |
| 735 | + // Downscale before encoding when the image exceeds the configured cap. | |
| 736 | + if ($target_dimensions !== null) { | |
| 737 | + [$new_w, $new_h] = $target_dimensions; | |
| 738 | + $resized = imagecreatetruecolor($new_w, $new_h); | |
| 739 | + if ($resized !== false) { | |
| 740 | + if ($is_png) { | |
| 741 | + // Preserve transparency on the resized canvas. | |
| 742 | + imagealphablending($resized, false); | |
| 743 | + imagesavealpha($resized, true); | |
| 744 | + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); | |
| 745 | + imagefilledrectangle($resized, 0, 0, $new_w, $new_h, $transparent); | |
| 746 | + } | |
| 747 | + imagecopyresampled( | |
| 748 | + $resized, $gd_img, | |
| 749 | + 0, 0, 0, 0, | |
| 750 | + $new_w, $new_h, | |
| 751 | + imagesx($gd_img), imagesy($gd_img) | |
| 752 | + ); | |
| 753 | + imagedestroy($gd_img); | |
| 754 | + $gd_img = $resized; | |
| 755 | + } | |
| 756 | + } | |
| 757 | + | |
| 291 | 758 | $success = match ($fmt) { |
| 292 | 759 | 'webp' => imagewebp($gd_img, $dest, $q), |
| 293 | 760 | 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false, |
| 294 | 761 | default => false, |
| @@ -294,9 +761,16 @@ | ||
| 294 | 761 | default => false, |
| 295 | 762 | }; |
| 296 | 763 | |
| 297 | 764 | imagedestroy($gd_img); |
| 298 | - return $success ? $dest : null; | |
| 765 | + | |
| 766 | + if (!$success || !file_exists($dest) || !filesize($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 | |
| 769 | + return null; | |
| 770 | + } | |
| 771 | + | |
| 772 | + return $dest; | |
| 299 | 773 | } |
| 300 | 774 | |
| 301 | 775 | /** |
| 302 | 776 | * Replace original file with converted version. |
| @@ -301,10 +775,17 @@ | ||
| 301 | 775 | /** |
| 302 | 776 | * Replace original file with converted version. |
| 303 | 777 | */ |
| 304 | 778 | private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void { |
| 305 | - @unlink($old_path); | |
| 779 | + if (!file_exists($new_path) || !filesize($new_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 | |
| 781 | + return; | |
| 782 | + } | |
| 306 | 783 | |
| 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. | |
| 307 | 788 | wp_update_post([ |
| 308 | 789 | 'ID' => $id, |
| 309 | 790 | 'post_mime_type' => "image/{$fmt}", |
| 310 | 791 | ]); |
| @@ -310,17 +791,125 @@ | ||
| 310 | 791 | ]); |
| 311 | 792 | |
| 312 | 793 | update_attached_file($id, $new_path); |
| 313 | 794 | $meta['file'] = _wp_relative_upload_path($new_path); |
| 795 | + | |
| 796 | + // Sync the full-size dimensions to the converted file. When a pre-conversion | |
| 797 | + // downscale shrank the image, the original width/height in metadata are now | |
| 798 | + // stale; otherwise this is a harmless no-op. | |
| 799 | + $new_dims = @getimagesize($new_path); | |
| 800 | + if ($new_dims && $new_dims[0] > 0 && $new_dims[1] > 0) { | |
| 801 | + $meta['width'] = (int) $new_dims[0]; | |
| 802 | + $meta['height'] = (int) $new_dims[1]; | |
| 803 | + } | |
| 804 | + | |
| 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); | |
| 314 | 811 | } |
| 315 | 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; | |
| 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 | + } | |
| 847 | + } | |
| 848 | + | |
| 849 | + /** | |
| 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. | |
| 853 | + */ | |
| 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; | |
| 857 | + | |
| 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 | + } | |
| 862 | + | |
| 863 | + if (!$base_path || !is_string($base_url_path) || $base_url_path === '') { | |
| 864 | + return null; | |
| 865 | + } | |
| 866 | + | |
| 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 | + | |
| 883 | + $batch_size = 500; | |
| 884 | + $like = '%' . $wpdb->esc_like($old_path) . '%'; | |
| 885 | + $max_batches = 100000; | |
| 886 | + | |
| 887 | + for ($batch = 0; $batch < $max_batches; $batch++) { | |
| 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 | |
| 889 | + "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) | |
| 890 | + WHERE post_content LIKE %s ORDER BY ID LIMIT %d", | |
| 891 | + $old_path, | |
| 892 | + $new_path, | |
| 893 | + $like, | |
| 894 | + $batch_size | |
| 895 | + )); | |
| 896 | + | |
| 897 | + // false → query error; a short batch (< batch_size) means the last | |
| 898 | + // matching rows were just rewritten. Either way there is no more work. | |
| 899 | + if ($affected === false || $affected < $batch_size) { | |
| 900 | + break; | |
| 901 | + } | |
| 902 | + } | |
| 903 | + } | |
| 904 | + | |
| 316 | 905 | // ── Instance Wrappers (Upload Hook) ── |
| 317 | 906 | |
| 318 | 907 | /** |
| 319 | 908 | * Instance wrapper around static conversion method. |
| 320 | 909 | */ |
| 321 | - private function convert_file(string $source, string $format, int $quality): ?string { | |
| 322 | - return self::do_convert_file($source, $format, $quality); | |
| 910 | + private function convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string { | |
| 911 | + return self::do_convert_file($source, $format, $quality, $max_dimensions); | |
| 323 | 912 | } |
| 324 | 913 | |
| 325 | 914 | /** |
| 326 | 915 | * Instance wrapper around static replace method. |
| @@ -329,78 +918,249 @@ | ||
| 329 | 918 | self::do_replace_original($id, $old_path, $new_path, $meta, $fmt); |
| 330 | 919 | } |
| 331 | 920 | |
| 332 | 921 | /** |
| 922 | + * Start output buffering on frontend to catch images from themes/builders | |
| 923 | + * that bypass standard WordPress image filters (e.g. Divi, Elementor). | |
| 924 | + */ | |
| 925 | + public function start_output_buffer(): void { | |
| 926 | + if (is_admin() || wp_doing_ajax() || wp_doing_cron()) { | |
| 927 | + return; | |
| 928 | + } | |
| 929 | + | |
| 930 | + if (defined('REST_REQUEST') && REST_REQUEST) { | |
| 931 | + return; | |
| 932 | + } | |
| 933 | + | |
| 934 | + if (is_feed() || is_robots() || is_trackback()) { | |
| 935 | + return; | |
| 936 | + } | |
| 937 | + | |
| 938 | + ob_start([$this, 'rewrite_full_html']); | |
| 939 | + } | |
| 940 | + | |
| 941 | + /** | |
| 942 | + * Output buffer callback: rewrite remaining <img> tags to <picture>. | |
| 943 | + * Protects existing <picture>, <script>, and <noscript> blocks from rewriting. | |
| 944 | + */ | |
| 945 | + public function rewrite_full_html(string $html): string { | |
| 946 | + if (empty($html) || stripos($html, '</html>') === false || $this->is_amp_request()) { | |
| 947 | + return $html; | |
| 948 | + } | |
| 949 | + | |
| 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); | |
| 954 | + | |
| 955 | + // Rewrite remaining <img> tags | |
| 956 | + $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) { | |
| 957 | + return $this->maybe_wrap_img_tag($matches[0]); | |
| 958 | + }, $html); | |
| 959 | + | |
| 960 | + return $this->restore_protected_blocks($html, $protected); | |
| 961 | + } | |
| 962 | + | |
| 963 | + /** | |
| 333 | 964 | * Rewrite <img> tags to <picture> with next-gen source. |
| 965 | + * Used by WordPress filter hooks for content fragments. | |
| 334 | 966 | */ |
| 335 | 967 | public function rewrite_to_picture_tags(string $content): string { |
| 336 | - if (empty($content)) { | |
| 968 | + if (empty($content) || is_feed() || $this->is_amp_request()) { | |
| 337 | 969 | return $content; |
| 338 | 970 | } |
| 339 | 971 | |
| 340 | - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters) | |
| 341 | - if (strpos($content, '<picture>') !== false) { | |
| 342 | - return $content; | |
| 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); | |
| 977 | + | |
| 978 | + $content = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) { | |
| 979 | + return $this->maybe_wrap_img_tag($matches[0]); | |
| 980 | + }, $content); | |
| 981 | + | |
| 982 | + return $this->restore_protected_blocks($content, $protected); | |
| 983 | + } | |
| 984 | + | |
| 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; | |
| 343 | 1014 | } |
| 1015 | + return strtr($html, $protected); | |
| 1016 | + } | |
| 344 | 1017 | |
| 345 | - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) { | |
| 346 | - $img_tag = $matches[0]; | |
| 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 | + } | |
| 347 | 1033 | |
| 348 | - // Check if this image should be excluded | |
| 349 | - if ($this->is_tag_excluded($img_tag)) { | |
| 350 | - return $img_tag; | |
| 351 | - } | |
| 1034 | + private static function func_is_available(string $function): bool { | |
| 1035 | + return function_exists($function); | |
| 1036 | + } | |
| 352 | 1037 | |
| 353 | - // Extract src | |
| 354 | - if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) { | |
| 355 | - return $img_tag; | |
| 356 | - } | |
| 1038 | + /** | |
| 1039 | + * Wrap a single <img> tag in <picture> with next-gen <source>. | |
| 1040 | + * Returns the original tag unchanged if conversion is not applicable. | |
| 1041 | + */ | |
| 1042 | + private function maybe_wrap_img_tag(string $img_tag): string { | |
| 1043 | + if ($this->is_tag_excluded($img_tag)) { | |
| 1044 | + return $img_tag; | |
| 1045 | + } | |
| 357 | 1046 | |
| 358 | - $original_src = $src_match[1]; | |
| 359 | - $format = $this->settings['conversion_format']; | |
| 360 | - $ext = self::get_format_extension($format); | |
| 1047 | + if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) { | |
| 1048 | + return $img_tag; | |
| 1049 | + } | |
| 361 | 1050 | |
| 362 | - // Build the converted file URL | |
| 363 | - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src); | |
| 1051 | + $original_src = $src_match[1]; | |
| 364 | 1052 | |
| 365 | - // Only wrap if the converted URL is different | |
| 366 | - if ($converted_url === $original_src) { | |
| 367 | - return $img_tag; | |
| 1053 | + // Exclusion entries may target the URL (full URL or path fragment). | |
| 1054 | + if ($this->is_url_excluded($original_src)) { | |
| 1055 | + return $img_tag; | |
| 1056 | + } | |
| 1057 | + | |
| 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) { | |
| 1064 | + return $img_tag; | |
| 1065 | + } | |
| 1066 | + | |
| 1067 | + [$converted_url, $format] = $resolved; | |
| 1068 | + $mime = $format === 'avif' ? 'image/avif' : 'image/webp'; | |
| 1069 | + | |
| 1070 | + $source_srcset = ''; | |
| 1071 | + if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) { | |
| 1072 | + $source_srcset = sprintf(' srcset="%s"', esc_attr($this->resolve_srcset_candidates($srcset_match[1]))); | |
| 1073 | + } | |
| 1074 | + | |
| 1075 | + $sizes_attr = ''; | |
| 1076 | + if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) { | |
| 1077 | + $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1])); | |
| 1078 | + } | |
| 1079 | + | |
| 1080 | + return sprintf( | |
| 1081 | + '<picture><source type="%s"%s%s>%s</picture>', | |
| 1082 | + esc_attr($mime), | |
| 1083 | + $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)), | |
| 1084 | + $sizes_attr, | |
| 1085 | + $img_tag | |
| 1086 | + ); | |
| 1087 | + } | |
| 1088 | + | |
| 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; | |
| 368 | 1109 | } |
| 369 | 1110 | |
| 370 | - // Verify the converted file actually exists on disk before rewriting | |
| 371 | - $converted_path = $this->url_to_path($converted_url); | |
| 372 | - if (!$converted_path || !file_exists($converted_path)) { | |
| 373 | - return $img_tag; | |
| 1111 | + $candidate_path = $this->url_to_path($candidate); | |
| 1112 | + if ($candidate_path && file_exists($candidate_path)) { | |
| 1113 | + return [$candidate, $format]; | |
| 374 | 1114 | } |
| 1115 | + } | |
| 375 | 1116 | |
| 376 | - $mime = $format === 'avif' ? 'image/avif' : 'image/webp'; | |
| 1117 | + return null; | |
| 1118 | + } | |
| 377 | 1119 | |
| 378 | - // Also handle srcset if present | |
| 379 | - $source_srcset = ''; | |
| 380 | - if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) { | |
| 381 | - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]); | |
| 382 | - $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset)); | |
| 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; | |
| 383 | 1145 | } |
| 384 | 1146 | |
| 385 | - // Extract sizes attribute to pass to <source> | |
| 386 | - $sizes_attr = ''; | |
| 387 | - if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) { | |
| 388 | - $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1])); | |
| 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; | |
| 389 | 1154 | } |
| 1155 | + } | |
| 390 | 1156 | |
| 391 | - return sprintf( | |
| 392 | - '<picture><source type="%s"%s%s>%s</picture>', | |
| 393 | - esc_attr($mime), | |
| 394 | - $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)), | |
| 395 | - $sizes_attr, | |
| 396 | - $img_tag | |
| 397 | - ); | |
| 398 | - }, $content); | |
| 1157 | + return $any_converted ? implode(', ', $out) : $srcset; | |
| 399 | 1158 | } |
| 400 | 1159 | |
| 401 | 1160 | /** |
| 402 | 1161 | * Convert a URL to a local file path. Returns null if URL is external. |
| 1162 | + * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation). | |
| 403 | 1163 | */ |
| 404 | 1164 | private function url_to_path(string $url): ?string { |
| 405 | 1165 | $upload_dir = wp_get_upload_dir(); |
| 406 | 1166 | $base_url = $upload_dir['baseurl']; |
| @@ -405,12 +1165,28 @@ | ||
| 405 | 1165 | $upload_dir = wp_get_upload_dir(); |
| 406 | 1166 | $base_url = $upload_dir['baseurl']; |
| 407 | 1167 | $base_path = $upload_dir['basedir']; |
| 408 | 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 | + | |
| 1175 | + // Direct match (same hostname) | |
| 409 | 1176 | if (strpos($url, $base_url) === 0) { |
| 410 | 1177 | return str_replace($base_url, $base_path, $url); |
| 411 | 1178 | } |
| 412 | 1179 | |
| 1180 | + // Path-based fallback: match uploads path regardless of hostname | |
| 1181 | + $base_url_path = wp_parse_url($base_url, PHP_URL_PATH); | |
| 1182 | + $url_path = wp_parse_url($url, PHP_URL_PATH); | |
| 1183 | + | |
| 1184 | + if ($base_url_path && $url_path && strpos($url_path, $base_url_path) === 0) { | |
| 1185 | + $relative = substr($url_path, strlen($base_url_path)); | |
| 1186 | + return $base_path . $relative; | |
| 1187 | + } | |
| 1188 | + | |
| 413 | 1189 | return null; |
| 414 | 1190 | } |
| 415 | 1191 | |
| 416 | 1192 | /** |
| @@ -424,8 +1200,33 @@ | ||
| 424 | 1200 | |
| 425 | 1201 | foreach ($exclude_urls as $pattern) { |
| 426 | 1202 | if (stripos($file, $pattern) !== false) { |
| 427 | 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 | + } | |
| 428 | 1229 | } |
| 429 | 1230 | } |
| 430 | 1231 | return false; |
| 431 | 1232 | } |