PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.12
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.12
4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 3.12.1 All 134 releases
optimole-wp / inc / media_offload.php

media_offload.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization 4.2.12, at inc/media_offload.php

2,833 lines 90.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optml_Media_Offload class.
4 *
5 * @package \Optimole\Inc
6 * @author Optimole <friends@optimole.com>
7 */
8
9 use OptimoleWP\Offload\Loader;
10 use Optimole\Sdk\Exception\InvalidArgumentException;
11 use Optimole\Sdk\Exception\InvalidUploadApiResponseException;
12 use Optimole\Sdk\Exception\RuntimeException;
13 use Optimole\Sdk\Exception\UploadApiException;
14 use Optimole\Sdk\Exception\UploadFailedException;
15 use Optimole\Sdk\Exception\UploadLimitException;
16 use Optimole\Sdk\Optimole;
17
18 /**
19 * Class Optml_Admin
20 */
21 class Optml_Media_Offload extends Optml_App_Replacer {
22 use Optml_Normalizer;
23 use Optml_Dam_Offload_Utils;
24
25
26 /**
27 * Hold the settings object.
28 *
29 * @var Optml_Settings Settings object.
30 */
31 public $settings;
32 /**
33 * Cached object instance.
34 *
35 * @var Optml_Media_Offload
36 */
37 private static $instance = null;
38
39 /**
40 * Hold the logger object.
41 *
42 * @var Optml_Logger
43 */
44 public $logger;
45
46 const KEYS = [
47 'uploaded_flag' => 'id:',
48 'not_processed_flag' => 'process:',
49 ];
50 const META_KEYS = [
51 'offloaded' => 'optimole_offload',
52 'offload_error' => 'optimole_offload_error',
53 'rollback_error' => 'optimole_rollback_error',
54 ];
55 const OM_OFFLOADED_FLAG = 'om_image_offloaded';
56 const POST_OFFLOADED_FLAG = 'optimole_offload_post';
57 const POST_ROLLBACK_FLAG = 'optimole_rollback_post';
58 const RETRYABLE_META_COUNTER = '_optimole_retryable_errors';
59
60 /**
61 * Transient name for the transfer lock.
62 */
63 const TRANSFER_LOCK_TRANSIENT = 'optml_transfer_lock';
64
65 /**
66 * Time to live for the transfer lock, in seconds.
67 */
68 const TRANSFER_LOCK_TTL = 600;
69
70 /**
71 * Flag used inside wp_get_attachment url filter.
72 *
73 * @var bool Whether or not to return the original url of the image.
74 */
75 private static $return_original_url = false;
76 /**
77 * Flag used inside wp_get_attachment url filter.
78 *
79 * @var bool Whether or not to return the original url of the image.
80 */
81 private static $offload_update_post = false;
82
83 /**
84 * Flag used inside wp_unique_filename filter.
85 *
86 * @var bool|string Whether to skip our custom deduplication.
87 */
88 private static $current_file_deduplication = false;
89 /**
90 * Keeps the last deduplicated lower case value.
91 *
92 * @var bool|string Used to check if the current processed image was deduplicated.
93 */
94 private static $last_deduplicated = false;
95 /**
96 * Checks if the plugin was installed before adding POST_OFFLOADED_FLAG.
97 *
98 * @var bool Used when applying the flags for the page query.
99 */
100 private static $is_legacy_install = null;
101
102 /**
103 * Adds page meta query args
104 *
105 * @param string $action The action for which the args are needed.
106 * @param array $args The initial args without the added meta_query args.
107 *
108 * @return array The args with the added meta_query args.
109 */
110 public static function add_page_meta_query_args( $action, $args ) {
111 if ( $action === 'offload_images' ) {
112 $args['meta_query'] = [
113 'relation' => 'AND',
114 [
115 'key' => self::POST_OFFLOADED_FLAG,
116 'compare' => 'NOT EXISTS',
117 ],
118 ];
119 }
120 if ( $action === 'rollback_images' ) {
121 $args['meta_query'] = [
122 'relation' => 'AND',
123 [
124 'key' => self::POST_ROLLBACK_FLAG,
125 'compare' => 'NOT EXISTS',
126 ],
127 ];
128 if ( self::$is_legacy_install ) {
129 $args['meta_query'][] = [
130 'key' => self::POST_OFFLOADED_FLAG,
131 'value' => 'true',
132 'compare' => '=',
133 ];
134 }
135 }
136
137 return $args;
138 }
139
140 /**
141 * Get count of all images from db.
142 *
143 * @return int Number of all images.
144 */
145 public static function number_of_all_images() {
146 $total_images_by_mime = wp_count_attachments( 'image' );
147
148 return array_sum( (array) $total_images_by_mime );
149 }
150
151 /**
152 * Optml_Media_Offload constructor.
153 */
154 public static function instance() {
155 if ( null === self::$instance || self::is_phpunit_test() ) {
156 self::$instance = new self();
157 self::$instance->settings = new Optml_Settings();
158 self::$instance->logger = Optml_Logger::instance();
159
160 if ( self::$instance->settings->is_connected() ) {
161 self::$instance->init();
162 }
163 if ( self::$instance->settings->is_offload_enabled() ) {
164 add_filter( 'image_downsize', [ self::$instance, 'generate_filter_downsize_urls' ], 10, 3 );
165 add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 );
166 add_filter( 'wp_get_attachment_url', [ self::$instance, 'get_image_attachment_url' ], - 999, 2 );
167 add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_uploaded_images' ] );
168
169 self::$instance->add_new_actions();
170
171 add_action( 'delete_attachment', [ self::$instance, 'delete_attachment_hook' ], 10 );
172 add_filter( 'handle_bulk_actions-upload', [ self::$instance, 'bulk_action_handler' ], 10, 3 );
173 // TODO: Uncomment this when bulk actions are implemented
174 // add_filter( 'bulk_actions-upload', [ self::$instance, 'register_bulk_media_actions' ] );
175 add_filter( 'media_row_actions', [ self::$instance, 'add_inline_media_action' ], 10, 2 );
176 add_filter( 'wp_calculate_image_srcset', [ self::$instance, 'calculate_image_srcset' ], 1, 5 );
177 add_action( 'post_updated', [ self::$instance, 'update_offload_meta' ], 10, 3 );
178
179 // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
180 $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
181 if ( $below_6_0_0 ) {
182 add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 );
183 } else {
184 add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 );
185 }
186
187 add_action( 'optml_start_processing_images', [ self::$instance, 'start_processing_images' ], 10, 6 );
188 add_action(
189 'optml_move_images_by_id',
190 [
191 self::$instance,
192 'move_single_image',
193 ],
194 10,
195 2
196 );
197 add_action( 'init', [ self::$instance, 'maybe_reschedule' ] );
198 if ( self::$is_legacy_install === null ) {
199 self::$is_legacy_install = get_option( 'optimole_wp_install', 0 ) > 1677171600;
200 }
201 ( new Loader() )->register_hooks();
202 }
203 }
204
205 return self::$instance;
206 }
207
208 /**
209 * Reschedule the transfer cron in case is missing or was lost.
210 *
211 * @return void
212 */
213 public function maybe_reschedule() {
214 $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
215 // If this is in pending, we do nothing.
216 if ( false !== $lock ) {
217 return;
218 }
219 // If there is no transfer in progress, we do nothing.
220 if ( self::$instance->settings->get( 'transfer_status' ) === 'disabled' ) {
221 return;
222 }
223 $transfer_type = self::$instance->settings->get( 'transfer_status' );
224 $in_progress = self::$instance->settings->get( 'rollback_images' === $transfer_type ? 'rollback_status' : 'offloading_status' ) !== 'disabled';
225 // We check if there is an in progress transfer.
226 if ( ! $in_progress ) {
227 return;
228 }
229 self::$instance->logger->add_log( $transfer_type, 'Cron missed, attempt to reschedule.' );
230 self::move_images( $transfer_type, false );
231 }
232
233 /**
234 * Function for `update_attached_file` filter-hook.
235 *
236 * @param string $file Path to the attached file to update.
237 * @param int $attachment_id Attachment ID.
238 *
239 * @return string
240 */
241 public function wp_update_attached_file_filter( $file, $attachment_id ) {
242
243 if ( OPTML_DEBUG_MEDIA ) {
244 do_action( 'optml_log', 'called updated attached' );
245 }
246 $info = pathinfo( $file );
247 $file_name = basename( $file );
248 $no_ext_file_name = basename( $file, '.' . $info['extension'] );
249 // if we have current deduplication set and it contains the filename that is updated
250 // we replace the updated filename with the deduplicated filename
251 if ( ! empty( self::$current_file_deduplication ) && stripos( self::$current_file_deduplication, $no_ext_file_name ) !== false ) {
252 $file = str_replace( $file_name, self::$current_file_deduplication, $file );
253 // we need to store the filename we replaced to check when uploading the image if it was deduplicated
254 self::$last_deduplicated = $file_name;
255
256 self::$current_file_deduplication = false;
257 }
258 if ( OPTML_DEBUG_MEDIA ) {
259 do_action( 'optml_log', self::$last_deduplicated );
260 }
261 remove_filter( 'update_attached_file', [ self::$instance, 'wp_update_attached_file_filter' ], 10 );
262
263 return $file;
264 }
265
266 /**
267 * Function for `wp_insert_attachment_data` filter-hook.
268 * Because we remove the images when new images are added the wp deduplication using the files will not work
269 * To overcome this we hook the attachment data when it's added to the database and we use the post name (slug) which is unique against the database
270 * For creating a unique quid by replacing the filename with the slug inside the existing guid
271 * This will ensure the guid is unique and the next step will be to make sure the attached_file meta for the image is also unique
272 * For this we will hook `update_attached_file` filter which is called after the data is inserted and there we will make sure we replace the filename
273 * with the deduplicated one which we stored into `$current_file_deduplication` variable
274 *
275 * @param array $data An array of slashed, sanitized, and processed attachment post data.
276 * @param array $postarr An array of slashed and sanitized attachment post data, but not processed.
277 * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data as originally passed to wp_insert_post().
278 * @param bool $update Whether this is an existing attachment post being updated.
279 *
280 * @return array
281 * @see self::insert_legacy() for backwards compatibility with older versions of WordPress < 6.0.0.
282 */
283 public function insert( $data, $postarr, $unsanitized_postarr, $update ) {
284
285 // the post name is unique against the database so not affected by removing the files
286 // https://developer.wordpress.org/reference/functions/wp_unique_post_slug/
287 if ( OPTML_DEBUG_MEDIA ) {
288 do_action( 'optml_log', 'data before' );
289 do_action( 'optml_log', $data );
290 }
291 if ( empty( $data['guid'] ) ) {
292 return $data;
293 }
294
295 $filename = wp_basename( $data['guid'] );
296 $ext = $this->get_ext( $filename );
297 // skip if the file is not an image
298 if ( ! isset( Optml_Config::$all_extensions[ $ext ] ) && ! in_array( $ext, [ 'jpg', 'jpeg', 'jpe' ], true ) ) {
299 return $data;
300 }
301
302 // on some instances (just unit tests) the post name has the extension appended like this : `image-1-jpg`
303 // we remove that as it is redundant for the file name deduplication we are using it
304 $sanitized_post_name = str_replace( '-' . $ext, '', $data['post_name'] );
305
306 // with the wp deduplication working the post_title is identical to the post_name
307 // so when they are different it means we need to deduplicate using the post_name
308 if ( ! empty( $data['post_name'] ) && $data['post_title'] !== $sanitized_post_name ) {
309 // we append the extension to the post_name to create a filename
310 // and use it to replace the filename in the guid
311 $no_ext_filename = str_replace( '.' . $ext, '', $filename );
312
313 $no_ext_filename_sanitized = sanitize_title( $no_ext_filename );
314
315 // get the deduplication addition from the database post_name
316 $diff = str_replace( strtolower( $no_ext_filename_sanitized ), '', $sanitized_post_name );
317
318 // create the deduplicated filename
319 $to_replace_with = $no_ext_filename . $diff . '.' . $ext;
320
321 $data['guid'] = str_replace( $filename, $to_replace_with, $data['guid'] );
322 // we store the deduplication to be used and add the filter for updating the attached_file meta
323 self::$current_file_deduplication = $to_replace_with;
324 add_filter( 'update_attached_file', [ self::$instance, 'wp_update_attached_file_filter' ], 10, 2 );
325 }
326 if ( OPTML_DEBUG_MEDIA ) {
327 do_action( 'optml_log', 'data after' );
328 do_action( 'optml_log', $data );
329 }
330
331 return $data;
332 }
333
334 /**
335 * Wrapper for the `insert` method for WP versions < 6.0.0.
336 *
337 * @param array $data An array of slashed, sanitized, and processed attachment post data.
338 * @param array $postarr An array of slashed and sanitized attachment post data, but not processed.
339 * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data as originally passed to wp_insert_post().
340 *
341 * @return array
342 */
343 public function insert_legacy( $data, $postarr, $unsanitized_postarr ) {
344 return $this->insert( $data, $postarr, $unsanitized_postarr, false );
345 }
346
347 /**
348 * Update offload meta when the page is updated.
349 *
350 * @param int $post_ID Updated post id.
351 * @param WP_Post $post_after Post before the update.
352 * @param WP_Post $post_before Post after the update.
353 *
354 * @return void
355 * @uses action:post_updated
356 */
357 public function update_offload_meta( $post_ID, $post_after, $post_before ) {
358 if ( self::$offload_update_post === true ) {
359 return;
360 }
361 if ( get_post_type( $post_ID ) === 'attachment' ) {
362 return;
363 }
364
365 // revisions are skipped inside the function no need to check them before
366 delete_post_meta( $post_ID, self::POST_ROLLBACK_FLAG );
367 }
368
369 /**
370 * Get image size name from width and meta.
371 *
372 * @param array $sizes Image sizes .
373 * @param integer $width Size width.
374 * @param string $filename Image filename.
375 *
376 * @return null|string|array
377 */
378 public static function get_image_size_from_width( $sizes, $width, $filename, $just_name = true ) {
379 foreach ( $sizes as $name => $size ) {
380 if ( $width === absint( $size['width'] ) && $size['file'] === $filename ) {
381 return $just_name ? $name : array_merge( $size, [ 'name' => $name ] );
382 }
383 }
384
385 return null;
386 }
387
388 /**
389 * Replace image URLs in the srcset attributes.
390 *
391 * @param mixed|array<int, array{url: string, descriptor: string, value: int}> $sources Array of image sources.
392 * @param array{0: int, 1: int} $size_array Array of width and height values in pixels (in that order).
393 * @param string $image_src The 'src' of the image.
394 * @param array<string, mixed> $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
395 * @param int $attachment_id Image attachment ID or 0.
396 *
397 * @return array<int, array{url: string, descriptor: string, value: int}>|mixed
398 */
399 public function calculate_image_srcset( $sources, $size_array, $image_src, $image_meta, $attachment_id ) {
400
401 if ( ! is_array( $sources ) ) {
402 return $sources;
403 }
404
405 if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
406 if ( ! Optml_Media_Offload::is_uploaded_image( $image_src ) || ! isset( $image_meta['file'] ) || ! Optml_Media_Offload::is_uploaded_image( $image_meta['file'] ) ) {
407 return $sources;
408 }
409 foreach ( $sources as $width => $source ) {
410 $filename = wp_basename( $image_meta['file'] );
411 $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename );
412 $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
413
414 if ( false === $optimized_url ) {
415 continue;
416 }
417
418 $sources[ $width ]['url'] = $optimized_url[0];
419 }
420
421 return $sources;
422 }
423
424 if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
425 return $sources;
426 }
427
428 $requested_width = $size_array[0];
429 $requested_height = $size_array[1];
430
431 if ( $requested_height < 1 || $requested_width < 1 ) {
432 return $sources;
433 }
434
435 $image_sizes = $this->get_all_image_sizes();
436 $crop = false;
437
438 // Loop through image sizes to make sure we're using the right cropping.
439 foreach ( $image_sizes as $size_name => $args ) {
440 if ( $args['width'] !== $requested_width && $args['height'] !== $requested_height ) {
441 continue;
442 }
443
444 if ( isset( $args['crop'] ) ) {
445 $crop = (bool) $args['crop'];
446 }
447 }
448 foreach ( $sources as $width => $source ) {
449 $filename = ( $image_meta['file'] );
450 $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename, false );
451
452 if ( $size === null || ! isset( $size['name'] ) ) {
453 unset( $sources[ $width ] );
454
455 continue;
456 }
457
458 if ( ! isset( $image_sizes[ $size['name'] ] ) || (bool) $image_sizes[ $size['name'] ]['crop'] !== $crop ) {
459 unset( $sources[ $width ] );
460
461 continue;
462 }
463
464 // Some image sizes might have 0 values for width or height.
465 if ( $size['width'] < 1 || $size['height'] < 1 ) {
466 unset( $sources[ $width ] );
467
468 continue;
469 }
470
471 if ( ! wp_image_matches_ratio( $size['width'], $size['height'], $requested_width, $requested_height ) ) {
472 continue;
473 }
474
475 $optimized_url = wp_get_attachment_image_src( $attachment_id, $size['name'] );
476
477 if ( false === $optimized_url ) {
478 unset( $sources[ $width ] );
479
480 continue;
481 }
482 $sources[ $width ]['url'] = $optimized_url[0];
483 }
484 // Add the requested size to the srcset.
485 $sources[ $requested_width ] = [
486 'url' => $image_src,
487 'descriptor' => 'w',
488 'value' => $requested_width,
489 ];
490
491 if ( $this->settings->get( 'retina_images' ) === 'enabled' ) {
492 $max_width = max( array_keys( $sources ) );
493 $sources[ $max_width * 2 ] = [
494 'url' => str_replace( '/w:', '/dpr:2/w:', $sources[ $max_width ]['url'] ),
495 'descriptor' => 'x',
496 'value' => 2,
497 ];
498 }
499 return $sources;
500 }
501
502 /**
503 * Check if the image is stored on our servers or not.
504 *
505 * @param string $src Image src or url.
506 *
507 * @return bool Whether image is upload or not.
508 */
509 public static function is_not_processed_image( $src ) {
510 return strpos( $src, self::KEYS['not_processed_flag'] ) !== false;
511 }
512
513 /**
514 * Check if the image is stored on our servers or not.
515 *
516 * @param string $src Image src or url.
517 *
518 * @return bool Whether image is upload or not.
519 */
520 public static function is_uploaded_image( $src ) {
521 return strpos( $src, '/' . self::KEYS['uploaded_flag'] ) !== false;
522 }
523
524 /**
525 * Get the attachment ID from the image tag.
526 *
527 * @param string $image Image tag.
528 *
529 * @return int|false
530 */
531 public function get_id_from_tag( $image ) {
532 $attachment_id = false;
533 if ( preg_match( '#class=["|\']?[^"\']*(wp-image-|wp-video-)([\d]+)[^"\']*["|\']?#i', $image, $found ) ) {
534 $attachment_id = intval( $found[2] );
535 }
536
537 return $attachment_id;
538 }
539
540 /**
541 * Get attachment id from url
542 *
543 * @param string $url The optimized url .
544 *
545 * @return false|mixed The attachment id .
546 */
547 public static function get_attachment_id_from_url( $url ) {
548 preg_match( '/\/' . Optml_Media_Offload::KEYS['not_processed_flag'] . '([^\/]*)\//', $url, $attachment_id );
549
550 return isset( $attachment_id[1] ) ? $attachment_id[1] : false;
551 }
552
553 /**
554 * Get attachment id from local url
555 *
556 * @param string $url The url to look for.
557 *
558 * @return array The attachment id and the size from the url.
559 */
560 public function get_local_attachement_id_from_url( $url ) {
561
562 $size = 'full';
563 $found_size = $this->parse_dimensions_from_filename( $url );
564 $url = $this->add_schema( $url );
565 if ( $found_size[0] !== false && $found_size[1] !== false ) {
566 $size = $found_size;
567
568 }
569 $url = $this->add_schema( $url );
570 $attachment_id = $this->attachment_url_to_post_id( $url );
571
572 return [ 'attachment_id' => $attachment_id, 'size' => $size ];
573 }
574
575 /**
576 * Filter out the urls that are saved to our servers when saving to the DB.
577 *
578 * @param array $data The post data array to save.
579 *
580 * @return array
581 * @uses filter:wp_insert_post_data
582 */
583 public function filter_uploaded_images( $data ) {
584
585 $content = trim( wp_unslash( $data['post_content'] ) );
586 if ( OPTML_DEBUG_MEDIA ) {
587 do_action( 'optml_log', 'content to update' );
588 do_action( 'optml_log', $content );
589 }
590 $images = Optml_Manager::instance()->extract_urls_from_content( $content );
591 if ( ! isset( $images[0] ) ) {
592 return $data;
593 }
594 if ( OPTML_DEBUG_MEDIA ) {
595 do_action( 'optml_log', 'images to update' );
596 do_action( 'optml_log', $images );
597 }
598 foreach ( $images as $url ) {
599 $is_original_uploaded = self::is_uploaded_image( $url );
600 $attachment_id = false;
601 $size = 'thumbnail';
602 if ( $is_original_uploaded ) {
603 $found_size = $this->parse_dimension_from_optimized_url( $url );
604 if ( $found_size[0] !== 'auto' && $found_size[1] !== 'auto' ) {
605 $size = $found_size;
606 }
607 $attachment_id = self::get_attachment_id_from_url( $url );
608 } else {
609 $id_and_size = $this->get_local_attachement_id_from_url( $url );
610 $attachment_id = $id_and_size['attachment_id'];
611 $size = $id_and_size['size'];
612 }
613
614 if ( OPTML_DEBUG_MEDIA ) {
615 do_action( 'optml_log', 'image id and found size' );
616 do_action( 'optml_log', $attachment_id );
617 do_action( 'optml_log', $size );
618 }
619 if ( false === $attachment_id || ! $this->is_legacy_offloaded_attachment( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) {
620 continue;
621 }
622 $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
623 if ( OPTML_DEBUG_MEDIA ) {
624 do_action( 'optml_log', ' image url to replace with ' );
625 do_action( 'optml_log', $optimized_url );
626 }
627
628 if ( ! isset( $optimized_url[0] ) ) {
629 continue;
630 }
631 if ( $is_original_uploaded === self::is_uploaded_image( $optimized_url[0] ) ) {
632 continue;
633 }
634 $content = str_replace( $url, $optimized_url[0], $content );
635 }
636 $data['post_content'] = wp_slash( $content );
637
638 return $data;
639 }
640
641 /**
642 * Get all images that need to be updated from a post.
643 *
644 * @param string $post_content The content of the post.
645 * @param string $job The job name.
646 *
647 * @return array An array containing the image ids.
648 */
649 public function get_image_id_from_content( $post_content, $job ) {
650 $content = trim( wp_unslash( $post_content ) );
651 $images = Optml_Manager::instance()->extract_urls_from_content( $content );
652 $found_images = [];
653 if ( isset( $images[0] ) ) {
654 foreach ( $images as $url ) {
655 $is_original_uploaded = self::is_uploaded_image( $url );
656 $attachment_id = false;
657 if ( $is_original_uploaded ) {
658 if ( $job === 'rollback_images' ) {
659 $attachment_id = self::get_attachment_id_from_url( $url );
660 }
661 } else {
662 if ( $job === 'offload_images' ) {
663 $id_and_size = $this->get_local_attachement_id_from_url( $url );
664 $attachment_id = $id_and_size['attachment_id'];
665 }
666 }
667 if ( false === $attachment_id || $attachment_id === 0 || ! wp_attachment_is_image( $attachment_id ) ) {
668 continue;
669 }
670 $found_images[] = intval( $attachment_id );
671 }
672 }
673
674 return apply_filters( 'optml_content_images_to_update', $found_images, $content );
675 }
676
677 /**
678 * Get the posts ids and the images from them that need sync/rollback.
679 *
680 * @param int $page The current page from the query.
681 * @param string $job The job name rollback_images/offload_images.
682 * @param int $batch How many posts to query on a page.
683 * @param array $page_in The pages that need to be updated.
684 *
685 * @return array An array containing the page of the query and an array containing the images for every post that need to be updated.
686 */
687 public function update_content( $page, $job, $batch = 1, $page_in = [] ) {
688 if ( OPTML_DEBUG_MEDIA ) {
689 do_action( 'optml_log', ' updating_content ' );
690 }
691 $post_types = array_values(
692 array_filter(
693 get_post_types(),
694 function ( $post_type ) {
695 if ( $post_type === 'attachment' || $post_type === 'revision' ) {
696 return false;
697 }
698
699 return true;
700 }
701 )
702 );
703 $query_args = apply_filters(
704 'optml_replacement_wp_query_args',
705 [
706 'post_type' => $post_types,
707 'post_status' => 'any',
708 'fields' => 'ids',
709 'posts_per_page' => $batch,
710 'update_post_meta_cache' => true,
711 'update_post_term_cache' => false,
712 ]
713 );
714
715 $query_args = self::add_page_meta_query_args( $job, $query_args );
716
717 if ( ! empty( $page_in ) ) {
718 $query_args['post__in'] = $page_in;
719 }
720
721 $content = new \WP_Query( $query_args );
722 if ( OPTML_DEBUG_MEDIA ) {
723 do_action( 'optml_log', $page );
724 }
725 $images_to_update = [];
726 if ( $content->have_posts() ) {
727 while ( $content->have_posts() ) {
728 $content->the_post();
729 $content_id = get_the_ID();
730 if ( get_post_type() !== 'attachment' ) {
731 $ids = $this->get_image_id_from_content( get_post_field( 'post_content', $content_id ), $job );
732 if ( count( $ids ) > 0 ) {
733 $images_to_update[ $content_id ] = $ids;
734 $duplicated_pages = apply_filters( 'optml_offload_duplicated_images', [], $content_id );
735 if ( is_array( $duplicated_pages ) && ! empty( $duplicated_pages ) ) {
736 foreach ( $duplicated_pages as $duplicated_id ) {
737 $duplicated_ids = $this->get_image_id_from_content( get_post_field( 'post_content', $duplicated_id ), $job );
738 $images_to_update[ $duplicated_id ] = $duplicated_ids;
739 }
740 }
741 }
742 if ( $job === 'offload_images' ) {
743 update_post_meta( $content_id, self::POST_OFFLOADED_FLAG, 'true' );
744 delete_post_meta( $content_id, self::POST_ROLLBACK_FLAG );
745 }
746 if ( $job === 'rollback_images' ) {
747 update_post_meta( $content_id, self::POST_ROLLBACK_FLAG, 'true' );
748 delete_post_meta( $content_id, self::POST_OFFLOADED_FLAG );
749 }
750 }
751 }
752 ++$page;
753 }
754 $result['page'] = $page;
755 $result['imagesToUpdate'] = $images_to_update;
756
757 return $result;
758 }
759
760 /**
761 * Add inline action to push to our servers.
762 *
763 * @param array $actions All actions.
764 * @param \WP_Post $post The current post image object.
765 *
766 * @return array
767 */
768 public function add_inline_media_action( $actions, $post ) {
769 $meta = wp_get_attachment_metadata( $post->ID );
770 if ( ! isset( $meta['file'] ) ) {
771 return $actions;
772 }
773 $file = $meta['file'];
774 if ( wp_check_filetype( $file, Optml_Config::$all_extensions )['ext'] === false || ! current_user_can( 'delete_post', $post->ID ) ) {
775 return $actions;
776 }
777 $actions['optml_actions'] = sprintf(
778 '<span class="spinner"></span><a class="move-image-optml %s" data-action="offload_image" href="#" aria-label="%s" data-id="%s">%s</a><a class="move-image-optml %s" data-action="rollback_image" href="#" aria-label="%s" data-id="%s">%s</a>',
779 self::is_uploaded_image( $file ) ? 'hidden' : '',
780 esc_attr__( 'Offload to Optimole', 'optimole-wp' ),
781 $post->ID,
782 esc_html__( 'Offload to Optimole', 'optimole-wp' ),
783 self::is_uploaded_image( $file ) ? '' : 'hidden',
784 esc_attr__( 'Restore image to media library', 'optimole-wp' ),
785 $post->ID,
786 esc_html__( 'Restore image to media library', 'optimole-wp' )
787 );
788
789 return $actions;
790 }
791
792 /**
793 * Upload images to our servers and update inside pages.
794 *
795 * @param array $image_ids The id of the attachments for the selected images.
796 *
797 * @return int The number of successfully processed images.
798 */
799 public function upload_and_update_existing_images( $image_ids ) {
800 $success_up = 0;
801 if ( OPTML_DEBUG_MEDIA ) {
802 do_action( 'optml_log', ' images to upload ' );
803 do_action( 'optml_log', $image_ids );
804 }
805 foreach ( $image_ids as $id ) {
806 if ( self::is_uploaded_image( wp_get_attachment_metadata( $id )['file'] ) ) {
807 // if this meta flag below failed at the initial update but the file meta above is updated it will cause an infinite query loop
808 update_post_meta( $id, self::META_KEYS['offloaded'], 'true' );
809 update_post_meta( $id, self::OM_OFFLOADED_FLAG, true );
810 ++$success_up;
811 continue;
812 }
813
814 $meta = $this->generate_image_meta( wp_get_attachment_metadata( $id ), $id );
815 if ( isset( $meta['file'] ) && self::is_uploaded_image( $meta['file'] ) ) {
816 ++$success_up;
817 wp_update_attachment_metadata( $id, $meta );
818 }
819 }
820 if ( $success_up > 0 ) {
821 if ( OPTML_DEBUG_MEDIA ) {
822 do_action( 'optml_log', ' call post update, succesful images: ' );
823 do_action( 'optml_log', $success_up );
824 }
825 }
826
827 return $success_up;
828 }
829
830 /**
831 * Return the original url of an image attachment.
832 *
833 * @param integer $post_id Image attachment id.
834 *
835 * @return string|bool The original url of the image.
836 */
837 public static function get_original_url( $post_id ) {
838 self::$return_original_url = true;
839 $original_url = wp_get_attachment_url( $post_id );
840 self::$return_original_url = false;
841
842 return $original_url;
843 }
844
845 /**
846 * Bring images back to media library and update inside pages.
847 *
848 * @param array $image_ids The id of the attachments for the selected images.
849 *
850 * @return int The number of successfully processed images.
851 */
852 public function rollback_and_update_images( $image_ids ) {
853 $success_back = 0;
854 if ( OPTML_DEBUG_MEDIA ) {
855 do_action( 'optml_log', ' images to rollback ' );
856 do_action( 'optml_log', $image_ids );
857 }
858
859 foreach ( $image_ids as $id ) {
860 // Skip DAM attachment filtering.
861 if ( $this->is_dam_imported_image( $id ) ) {
862 continue;
863 }
864 $current_meta = wp_get_attachment_metadata( $id );
865 if ( ! isset( $current_meta['file'] ) || ! self::is_uploaded_image( $current_meta['file'] ) ) {
866 delete_post_meta( $id, self::META_KEYS['offloaded'] );
867 delete_post_meta( $id, self::OM_OFFLOADED_FLAG );
868 ++$success_back;
869 continue;
870 }
871
872 // Account for scaled images.
873 $source_file = isset( $current_meta['original_image'] ) ? $current_meta['original_image'] : $current_meta['file'];
874 $filename = pathinfo( $source_file, PATHINFO_BASENAME );
875 $image_id = preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $current_meta['file'], $matches ) ? $matches[1] : null;
876
877 if ( null === $image_id ) {
878 continue;
879 }
880
881 if ( OPTML_DEBUG_MEDIA ) {
882 do_action( 'optml_log', ' image cloud id ' );
883 do_action( 'optml_log', $image_id );
884 }
885
886 $image_url = Optimole::offload()->getImageUrl( $image_id );
887
888 if ( null === $image_url ) {
889 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
890 if ( OPTML_DEBUG_MEDIA ) {
891 do_action( 'optml_log', ' error get url' );
892 }
893
894 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error getting URL.' );
895
896 continue;
897 }
898
899 if ( ! function_exists( 'download_url' ) ) {
900 include_once ABSPATH . 'wp-admin/includes/file.php';
901 }
902 if ( ! function_exists( 'download_url' ) ) {
903 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
904 continue;
905 }
906 $timeout_seconds = 60;
907 $temp_file = download_url( $image_url, $timeout_seconds );
908
909 if ( is_wp_error( $temp_file ) ) {
910 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
911 if ( OPTML_DEBUG_MEDIA ) {
912 do_action( 'optml_log', ' download_url error ' );
913 }
914
915 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error downloading URL.' );
916 continue;
917 }
918
919 $extension = $this->get_ext( $filename );
920
921 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
922 if ( OPTML_DEBUG_MEDIA ) {
923 do_action( 'optml_log', ' image has invalid extension' );
924 do_action( 'optml_log', $extension );
925 }
926 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
927
928 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has invalid extension.' );
929 continue;
930 }
931
932 $type = Optml_Config::$image_extensions [ $extension ];
933 $file = [
934 'name' => $filename,
935 'type' => $type,
936 'tmp_name' => $temp_file,
937 'error' => 0,
938 'size' => filesize( $temp_file ),
939 ];
940
941 $overrides = [
942 // do not expect the default form data from normal uploads
943 'test_form' => false,
944
945 // Setting this to false lets WordPress allow empty files, not recommended.
946 'test_size' => true,
947
948 // A properly uploaded file will pass this test. There should be no reason to override this one.
949 'test_upload' => true,
950 ];
951
952 if ( ! function_exists( 'wp_handle_sideload' ) ) {
953 include_once ABSPATH . '/wp-admin/includes/file.php';
954 }
955 if ( ! function_exists( 'wp_handle_sideload' ) ) {
956 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
957 continue;
958 }
959
960 // Move the temporary file into the uploads directory.
961 $results = wp_handle_sideload( $file, $overrides, get_the_date( 'Y/m', $id ) );
962 if ( ! empty( $results['error'] ) ) {
963 if ( OPTML_DEBUG_MEDIA ) {
964 do_action( 'optml_log', ' wp_handle_sideload error' );
965 }
966 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
967
968 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' faced wp_handle_sideload error.' );
969 continue;
970 }
971
972 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
973 include_once ABSPATH . '/wp-admin/includes/image.php';
974 }
975 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
976 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
977 continue;
978 }
979 $original_meta = wp_create_image_subsizes( $results['file'], $id );
980 if ( $type === 'image/svg+xml' ) {
981 if ( ! function_exists( 'wp_get_attachment_metadata' ) || ! function_exists( 'wp_update_attachment_metadata' ) ) {
982 include_once ABSPATH . '/wp-admin/includes/post.php';
983 }
984 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
985 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
986 continue;
987 }
988 $meta = wp_get_attachment_metadata( $id );
989 if ( ! isset( $meta['file'] ) ) {
990 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
991 continue;
992 }
993 $meta['file'] = $results['file'];
994 wp_update_attachment_metadata( $id, $meta );
995 }
996
997 if ( ! function_exists( 'update_attached_file' ) ) {
998 include_once ABSPATH . '/wp-admin/includes/post.php';
999 }
1000 if ( ! function_exists( 'update_attached_file' ) ) {
1001 update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
1002 continue;
1003 }
1004 update_attached_file( $id, $results['file'] );
1005
1006 $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $id );
1007 if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1008 foreach ( $duplicated_images as $duplicated_id ) {
1009 $duplicated_meta = wp_get_attachment_metadata( $duplicated_id );
1010 if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1011 $duplicated_meta['file'] = $results['file'];
1012 if ( isset( $meta ) ) {
1013 foreach ( $meta['sizes'] as $key => $value ) {
1014 if ( isset( $original_meta['sizes'][ $key ]['file'] ) ) {
1015 $duplicated_meta['sizes'][ $key ]['file'] = $original_meta['sizes'][ $key ]['file'];
1016 }
1017 }
1018 }
1019 wp_update_attachment_metadata( $duplicated_id, $duplicated_meta );
1020 delete_post_meta( $duplicated_id, self::META_KEYS['offloaded'] );
1021 delete_post_meta( $duplicated_id, self::OM_OFFLOADED_FLAG );
1022 }
1023 }
1024 }
1025 ++$success_back;
1026
1027 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has been rolled back.' );
1028
1029 $original_url = self::get_original_url( $id );
1030 if ( $original_url === false ) {
1031 continue;
1032 }
1033 $this->delete_attachment_from_server( $original_url, $id, $image_id );
1034 }
1035
1036 if ( $success_back > 0 ) {
1037 if ( OPTML_DEBUG_MEDIA ) {
1038 do_action( 'optml_log', ' call update post, success rollback' );
1039 do_action( 'optml_log', $success_back );
1040 }
1041 }
1042
1043 return $success_back;
1044 }
1045
1046 /**
1047 * Handle the bulk actions.
1048 *
1049 * @param string $redirect The current url from the media library.
1050 * @param string $doaction The current action selected.
1051 * @param array $image_ids The id of the attachments for the selected images.
1052 *
1053 * @return string The url with the correspondent query args for the executed actions.
1054 */
1055 public function bulk_action_handler( $redirect, $doaction, $image_ids ) {
1056
1057 if ( empty( $image_ids ) ) {
1058 return $redirect;
1059 }
1060
1061 $image_ids = array_slice( $image_ids, 0, 20, true );
1062 $redirect = 'admin.php';
1063 $redirect = add_query_arg( 'optimole_action', $doaction, $redirect );
1064 $redirect = add_query_arg( 'page', 'optimole', $redirect );
1065 $redirect = add_query_arg( $image_ids, $redirect );
1066
1067 return $redirect;
1068 }
1069
1070 /**
1071 * Register the bulk media actions.
1072 *
1073 * @param array $bulk_array The existing actions array.
1074 *
1075 * @return array The array with the appended actions.
1076 */
1077 public function register_bulk_media_actions( $bulk_array ) {
1078
1079 $bulk_array['offload_images'] = __( 'Push Image to Optimole', 'optimole-wp' );
1080 $bulk_array['rollback_images'] = __( 'Restore image to media library', 'optimole-wp' );
1081
1082 return $bulk_array;
1083 }
1084
1085 /**
1086 * Send delete request to our servers and update the meta.
1087 *
1088 * @param string $original_url Original url of the image.
1089 * @param integer $post_id Image id inside db.
1090 * @param string $image_id Our cloud id for the image.
1091 */
1092 public function delete_attachment_from_server( $original_url, $post_id, $image_id ) {
1093 Optimole::offload()->deleteImage( $image_id );
1094
1095 delete_post_meta( $post_id, self::META_KEYS['offloaded'] );
1096 delete_post_meta( $post_id, self::OM_OFFLOADED_FLAG );
1097 }
1098
1099 /**
1100 * Delete an image from our servers after it is removed from media.
1101 *
1102 * @param int $post_id The deleted post id.
1103 */
1104 public function delete_attachment_hook( $post_id ) {
1105 $file = wp_get_attachment_metadata( $post_id );
1106 if ( $file === false ) {
1107 return;
1108 }
1109
1110 // Skip if the image was imported from cloud library.
1111 if ( $this->is_dam_imported_image( $post_id ) ) {
1112 return;
1113 }
1114
1115 if ( ! $this->is_new_offloaded_attachment( $post_id ) && ! $this->is_legacy_offloaded_attachment( $post_id ) ) {
1116 return;
1117 }
1118
1119 $file = $file['file'];
1120 if ( self::is_uploaded_image( $file ) || $this->is_new_offloaded_attachment( $post_id ) ) {
1121 $original_url = self::get_original_url( $post_id );
1122 if ( $original_url === false ) {
1123 return;
1124 }
1125 $table_id = [];
1126
1127 preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $file, $table_id );
1128
1129 if ( ! isset( $table_id[1] ) ) {
1130 return;
1131 }
1132 $this->delete_attachment_from_server( $original_url, $post_id, $table_id[1] );
1133 }
1134 }
1135
1136 /**
1137 * Get optimized URL for an attachment image if it is uploaded to our servers.
1138 *
1139 * @param string $url The current url.
1140 * @param int $attachment_id The attachment image id.
1141 *
1142 * @return string Optimole cdn URL.
1143 * @uses filter:wp_get_attachment_url
1144 */
1145 public function get_image_attachment_url( $url, $attachment_id ) {
1146 if ( self::$return_original_url === true ) {
1147 return $url;
1148 }
1149
1150 if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
1151 $meta = wp_get_attachment_metadata( $attachment_id );
1152 if ( ! isset( $meta['file'] ) ) {
1153 return $url;
1154 }
1155
1156 // Skip DAM attachment filtering.
1157 if ( $this->is_dam_imported_image( $attachment_id ) ) {
1158 return $url;
1159 }
1160
1161 $file = $meta['file'];
1162 if ( self::is_uploaded_image( $file ) ) {
1163 return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $attachment_id . $file, $this->get_optimized_image_url( $url, 'auto', 'auto' ) );
1164 } else {
1165 // this is for the users that already offloaded the images before the other fixes
1166 $local_file = get_attached_file( $attachment_id );
1167 if ( ! file_exists( $local_file ) ) {
1168 $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
1169 if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1170 foreach ( $duplicated_images as $id ) {
1171 if ( ! empty( $id ) ) {
1172 $duplicated_meta = wp_get_attachment_metadata( $id );
1173 if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1174 return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $id . $duplicated_meta['file'], $this->get_optimized_image_url( $url, 'auto', 'auto' ) );
1175 }
1176 }
1177 }
1178 }
1179 }
1180 }
1181 return $url;
1182 }
1183
1184 if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
1185 return $url;
1186 }
1187
1188 return $this->get_new_offloaded_attachment_url( $url, $attachment_id );
1189 }
1190
1191 /**
1192 * Filter the requested image url.
1193 *
1194 * @param bool|array $image The previous image value (null).
1195 * @param int $attachment_id The ID of the attachment.
1196 * @param string|array $size Requested size of image. Image size name, or array of width and height values (in that order).
1197 *
1198 * @return bool|array The image sizes and optimized url.
1199 * @uses filter:image_downsize
1200 */
1201 public function generate_filter_downsize_urls( $image, $attachment_id, $size ) {
1202 if ( $this->is_dam_imported_image( $attachment_id ) ) {
1203 return $image;
1204 }
1205
1206 if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
1207 if ( self::$return_original_url === true ) {
1208 return $image;
1209 }
1210
1211 $sizes2crop = self::size_to_crop();
1212 if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) {
1213 return $image;
1214 }
1215 $data = image_get_intermediate_size( $attachment_id, $size );
1216 if ( false === $data || ! self::is_uploaded_image( $data['url'] ) ) {
1217 return $image;
1218 }
1219 $resize = apply_filters( 'optml_default_crop', [] );
1220 if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) {
1221 $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] );
1222 }
1223 $id_filename = [];
1224
1225 preg_match( '/\/(' . self::KEYS['not_processed_flag'] . '.*)/', $data['url'], $id_filename );
1226 if ( ! isset( $id_filename[1] ) ) {
1227 return $image;
1228 }
1229 $url = self::get_original_url( $attachment_id );
1230
1231 return [
1232 str_replace( $url, $id_filename[1], $this->get_optimized_image_url( $url, $data['width'], $data['height'], $resize ) ),
1233 $data['width'],
1234 $data['height'],
1235 true,
1236 ];
1237 }
1238
1239 if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
1240 return $image;
1241 }
1242
1243 return $this->alter_attachment_image_src( $image, $attachment_id, $size, false );
1244 }
1245
1246 /**
1247 * Get image extension.
1248 *
1249 * @param string $path Image path.
1250 *
1251 * @return string
1252 */
1253 private function get_ext( $path ) {
1254 return pathinfo( $path, PATHINFO_EXTENSION );
1255 }
1256
1257 /**
1258 * Mark an image as having a retryable error.
1259 *
1260 * @param int $attachment_id The attachment ID.
1261 * @param string $reason The reason for the error.
1262 */
1263 public static function mark_retryable_error( $attachment_id, $reason ) {
1264 static $allowed_retries = 5;
1265
1266 $retries = get_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, true );
1267 $retries = empty( $retries ) ? 0 : (int) $retries;
1268 if ( $retries >= $allowed_retries ) {
1269 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Reached the maximum number of retries.' );
1270 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1271
1272 return;
1273 }
1274
1275 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Marked for retry, retries done: ' . $retries );
1276
1277 update_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, ( $retries + 1 ) );
1278 }
1279
1280 /**
1281 * Update image meta with optimized cdn path.
1282 *
1283 * @param array $meta Meta information of the image.
1284 * @param int $attachment_id The image attachment ID.
1285 *
1286 * @return array
1287 * @uses filter:wp_generate_attachment_metadata
1288 */
1289 public function generate_image_meta( $meta, $attachment_id ) {
1290
1291 if ( $this->is_dam_imported_image( $attachment_id ) ) {
1292 return $meta;
1293 }
1294
1295 if ( self::$instance->settings->is_offload_limit_reached() ) {
1296 return $meta;
1297 }
1298
1299 if ( OPTML_DEBUG_MEDIA ) {
1300 do_action( 'optml_log', 'called generate meta' );
1301 }
1302 // No meta, or image was already uploaded.
1303 if ( ! isset( $meta['file'] ) || ! isset( $meta['width'] ) || ! isset( $meta['height'] ) || self::is_uploaded_image( $meta['file'] ) ) {
1304 do_action( 'optml_log', 'invalid meta' );
1305 do_action( 'optml_log', $meta );
1306 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1307
1308 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid meta.' );
1309
1310 return $meta;
1311 }
1312 // Skip images based on filters.
1313 if ( false === Optml_Filters::should_do_image( $meta['file'], self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_FILENAME ] ) ) {
1314 do_action( 'optml_log', 'optimization filter' );
1315 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1316
1317 return $meta;
1318 }
1319 $original_url = self::get_original_url( $attachment_id );
1320
1321 // Could not find original URL.
1322 if ( $original_url === false ) {
1323 do_action( 'optml_log', 'error getting original url' );
1324 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1325
1326 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid original url.' );
1327
1328 return $meta;
1329 }
1330
1331 // We should strip the `-scaled` from the URL to not generate inconsistencies with automatically scaled images.
1332 $original_url = $this->maybe_strip_scaled( $original_url );
1333 $local_file = $this->maybe_strip_scaled( get_attached_file( $attachment_id ) );
1334
1335 $extension = $this->get_ext( $local_file );
1336 $content_type = Optml_Config::$image_extensions [ $extension ];
1337 $temp = explode( '/', $local_file );
1338 $file_name = end( $temp );
1339 $no_ext_filename = str_replace( '.' . $extension, '', $file_name );
1340 $original_name = $file_name;
1341 if ( OPTML_DEBUG_MEDIA ) {
1342 do_action( 'optml_log', 'file before replace' );
1343 do_action( 'optml_log', $local_file );
1344 }
1345
1346 // check if the current filename is the last deduplicated filename
1347 if ( ! empty( self::$last_deduplicated ) && strpos( $no_ext_filename, str_replace( '.' . $extension, '', self::$last_deduplicated ) ) !== false ) {
1348 // replace the file with the original before deduplication to get the path where the image is uploaded
1349 $local_file = str_replace( $file_name, self::$last_deduplicated, $local_file );
1350 $original_name = self::$last_deduplicated;
1351 self::$last_deduplicated = false;
1352 }
1353 if ( OPTML_DEBUG_MEDIA ) {
1354 do_action( 'optml_log', 'file after replace' );
1355 do_action( 'optml_log', $local_file );
1356 }
1357 if ( ! file_exists( $local_file ) ) {
1358 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1359 do_action( 'optml_log', 'missing file' );
1360 do_action( 'optml_log', $local_file );
1361
1362 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has missing file.' );
1363
1364 return $meta;
1365 }
1366
1367 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
1368 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1369 do_action( 'optml_log', 'invalid extension' );
1370 do_action( 'optml_log', $extension );
1371
1372 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid extension.' );
1373
1374 return $meta;
1375 }
1376 if ( false === Optml_Filters::should_do_extension( self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_EXT ], $extension ) ) {
1377 do_action( 'optml_log', 'extension filter' );
1378 do_action( 'optml_log', $extension );
1379 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1380
1381 return $meta;
1382 }
1383
1384 $offload_manager = Optimole::offload();
1385 $offload_usage = $offload_manager->getUsage();
1386
1387 $current_run = self::get_process_meta();
1388 $remaining = isset( $current_run['remaining'] ) ? absint( $current_run['remaining'] ) : 0;
1389
1390 if ( $remaining + $offload_usage->getCurrent() >= $offload_usage->getLimit() ) {
1391 if ( OPTML_DEBUG_MEDIA ) {
1392 do_action( 'optml_log', 'limit exceeded' );
1393 do_action( 'optml_log', $offload_usage );
1394 }
1395
1396 self::$instance->settings->update( 'offload_limit_reached', 'enabled' );
1397
1398 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: offloading images would exceed limit.' );
1399
1400 return $meta;
1401 }
1402
1403 try {
1404 $image_id = $offload_manager->uploadImage( $local_file, $original_url );
1405
1406 if ( OPTML_DEBUG_MEDIA ) {
1407 do_action( 'optml_log', 'image id' );
1408 do_action( 'optml_log', $image_id );
1409 }
1410
1411 // We clear the retry counter if we reach this point.
1412 delete_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER );
1413 } catch ( InvalidArgumentException $exception ) {
1414 if ( OPTML_DEBUG_MEDIA ) {
1415 do_action( 'optml_log', 'invalid argument exception' );
1416 do_action( 'optml_log', $exception );
1417 }
1418
1419 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1420
1421 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' file is missing or unreadable.' );
1422
1423 return $meta;
1424 } catch ( InvalidUploadApiResponseException $exception ) {
1425 if ( OPTML_DEBUG_MEDIA ) {
1426 do_action( 'optml_log', 'missing table id or upload url' );
1427 do_action( 'optml_log', $exception );
1428 }
1429
1430 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1431
1432 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid table id or upload url.' );
1433
1434 return $meta;
1435 } catch ( UploadFailedException $exception ) {
1436 if ( OPTML_DEBUG_MEDIA ) {
1437 do_action( 'optml_log', 'upload error' );
1438 do_action( 'optml_log', $exception );
1439 }
1440
1441 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1442
1443 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has upload error.' );
1444
1445 return $meta;
1446 } catch ( UploadLimitException $exception ) {
1447 if ( OPTML_DEBUG_MEDIA ) {
1448 do_action( 'optml_log', 'limit exceeded' );
1449 do_action( 'optml_log', $exception );
1450 }
1451
1452 self::$instance->settings->update( 'offload_limit', $exception->getUsage()->getLimit() );
1453 self::$instance->settings->update( 'offload_limit_reached', 'enabled' );
1454
1455 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: upload limit exceeded' );
1456
1457 return $meta;
1458 } catch ( UploadApiException $exception ) {
1459 if ( OPTML_DEBUG_MEDIA ) {
1460 do_action( 'optml_log', 'upload api error' );
1461 do_action( 'optml_log', $exception );
1462 }
1463
1464 self::mark_retryable_error( $attachment_id, 'Error from upload api:' . $exception->getMessage() );
1465
1466 return $meta;
1467 } catch ( RuntimeException $exception ) {
1468 if ( OPTML_DEBUG_MEDIA ) {
1469 do_action( 'optml_log', 'runtime exception' );
1470 do_action( 'optml_log', $exception );
1471 }
1472
1473 self::mark_retryable_error( $attachment_id, 'Unknown error from upload api: ' . $exception->getMessage() );
1474
1475 return $meta;
1476 }
1477
1478 $url_to_append = $original_url;
1479 $url_parts = parse_url( $original_url );
1480
1481 if ( isset( $url_parts['scheme'] ) && isset( $url_parts['host'] ) ) {
1482 $url_to_append = $url_parts['scheme'] . '://' . $url_parts['host'] . '/' . $file_name;
1483 }
1484
1485 $optimized_url = $this->get_media_optimized_url( $url_to_append, $image_id );
1486
1487 if ( ( new Optml_Api() )->check_optimized_url( $optimized_url ) === false ) {
1488 do_action( 'optml_log', 'optimization error' );
1489 do_action( 'optml_log', $optimized_url );
1490
1491 Optimole::offload()->deleteImage( $image_id );
1492
1493 update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1494
1495 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has optimization error.' );
1496
1497 return $meta;
1498 }
1499
1500 @unlink( $local_file );
1501
1502 update_post_meta( $attachment_id, self::META_KEYS['offloaded'], 'true' );
1503 update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true );
1504
1505 $meta['file'] = '/' . self::KEYS['uploaded_flag'] . $image_id . '/' . $url_to_append;
1506
1507 if ( isset( $meta['sizes'] ) ) {
1508 foreach ( $meta['sizes'] as $key => $value ) {
1509 $generated_image_size_path = str_replace( $original_name, $meta['sizes'][ $key ]['file'], $local_file );
1510 file_exists( $generated_image_size_path ) && unlink( $generated_image_size_path );
1511 $meta['sizes'][ $key ]['file'] = $file_name;
1512 }
1513 }
1514
1515 // This is needed for scaled images.
1516 // Otherwise, `-scaled` images will be left behind.
1517 if ( isset( $meta['original_image'] ) ) {
1518 $ext = $this->get_ext( $local_file );
1519 $scaled_path = str_replace( '.' . $ext, '-scaled.' . $ext, $local_file );
1520 file_exists( $scaled_path ) && unlink( $scaled_path );
1521 }
1522
1523 $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
1524
1525 if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1526 foreach ( $duplicated_images as $duplicated_id ) {
1527 $duplicated_meta = wp_get_attachment_metadata( $duplicated_id );
1528 if ( isset( $duplicated_meta['file'] ) && ! self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1529 $duplicated_meta['file'] = $meta['file'];
1530 if ( $duplicated_meta['sizes'] ) {
1531 foreach ( $meta['sizes'] as $key => $value ) {
1532 $duplicated_meta['sizes'][ $key ]['file'] = $file_name;
1533 }
1534 }
1535 wp_update_attachment_metadata( $duplicated_id, $duplicated_meta );
1536 update_post_meta( $duplicated_id, self::META_KEYS['offloaded'], 'true' );
1537 update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true );
1538 }
1539 }
1540 }
1541 if ( OPTML_DEBUG_MEDIA ) {
1542 do_action( 'optml_log', 'success offload' );
1543 }
1544
1545 self::decrement_process_meta_remaining();
1546 self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has been offloaded.' );
1547 $attachment_page_id = wp_get_post_parent_id( $attachment_id );
1548
1549 if ( $attachment_page_id !== false && $attachment_page_id !== 0 ) {
1550 self::$offload_update_post = true;
1551 update_post_meta( $attachment_page_id, self::POST_OFFLOADED_FLAG, 'true' );
1552 self::$offload_update_post = false;
1553 }
1554
1555 return $meta;
1556 }
1557
1558 /**
1559 * Get the args for wp query according to the scope.
1560 *
1561 * @param int $batch Number of images to get.
1562 * @param string $action The action for which to get the images.
1563 *
1564 * @return array|false The query options array or false if not passed a valid action.
1565 */
1566 public static function get_images_or_pages_query_args( $batch, $action, $get_images = false ) {
1567
1568 $args = [
1569 'posts_per_page' => $batch,
1570 'fields' => 'ids',
1571 'ignore_sticky_posts' => false,
1572 'no_found_rows' => true,
1573 ];
1574
1575 if ( $get_images === true ) {
1576 $args['post_type'] = 'attachment';
1577 $args['post_mime_type'] = 'image';
1578 $args['post_status'] = 'inherit';
1579
1580 // Offload args.
1581 if ( $action === 'offload_images' ) {
1582 $args['meta_query'] = [
1583 'relation' => 'AND',
1584 [
1585 'key' => self::META_KEYS['offloaded'],
1586 'compare' => 'NOT EXISTS',
1587 ],
1588 [
1589 'key' => self::META_KEYS['offload_error'],
1590 'compare' => 'NOT EXISTS',
1591 ],
1592 ];
1593
1594 return $args;
1595 }
1596
1597 // Rollback args.
1598 $args['meta_query'] = [
1599 'relation' => 'AND',
1600 [
1601 'key' => self::META_KEYS['offloaded'],
1602 'value' => 'true',
1603 'compare' => '=',
1604 ],
1605 [
1606 'key' => self::META_KEYS['rollback_error'],
1607 'compare' => 'NOT EXISTS',
1608 ],
1609 [
1610 'key' => Optml_Dam::OM_DAM_IMPORTED_FLAG,
1611 'compare' => 'NOT EXISTS',
1612 ],
1613 ];
1614
1615 return $args;
1616 }
1617
1618 $args = self::add_page_meta_query_args( $action, $args );
1619 $post_types = array_filter(
1620 get_post_types(),
1621 function ( $post_type ) {
1622 if ( $post_type === 'attachment' || $post_type === 'revision' ) {
1623 return false;
1624 }
1625
1626 return true;
1627 }
1628 );
1629
1630 $args ['post_type'] = array_values( $post_types );
1631
1632 return $args;
1633 }
1634
1635 /**
1636 * Query the database and upload images to our servers.
1637 *
1638 * @param int $batch Number of images to process in a batch.
1639 *
1640 * @return array Number of found images and number of successfully processed images.
1641 */
1642 public function upload_images( $batch, $images = [] ) {
1643 self::$instance->settings->update( 'offload_limit_reached', 'disabled' );
1644
1645 if ( empty( $images ) || $images === 'none' ) {
1646 $args = self::get_images_or_pages_query_args( $batch, 'offload_images', true );
1647 $attachments = new \WP_Query( $args );
1648 $ids = $attachments->get_posts();
1649 } else {
1650 $ids = array_slice( $images, 0, $batch );
1651 }
1652 $result = [ 'found_images' => count( $ids ) ];
1653 $result['success_offload'] = $this->upload_and_update_existing_images( $ids );
1654
1655 return $result;
1656 }
1657
1658 /**
1659 * Query the database and bring back image to media library.
1660 *
1661 * @param int $batch Number of images to process in a batch.
1662 *
1663 * @return array Number of found images and number of successfully processed images.
1664 */
1665 public function rollback_images( $batch, $images = [] ) {
1666 if ( empty( $images ) || $images === 'none' ) {
1667 $args = self::get_images_or_pages_query_args( $batch, 'rollback_images', true );
1668 $attachments = new \WP_Query( $args );
1669 $ids = $attachments->get_posts();
1670 } else {
1671 $ids = array_slice( $images, 0, $batch );
1672 }
1673 $result = [ 'found_images' => count( $ids ) ];
1674 $result['success_rollback'] = $this->rollback_and_update_images( $ids );
1675
1676 return $result;
1677 }
1678
1679 /**
1680 * Update the post with the given id, the images will be updated by the filters we use.
1681 *
1682 * @param int $post_id The post id to update.
1683 *
1684 * @return bool Whether the update was succesful or not.
1685 */
1686 public function update_page( $post_id ) {
1687 self::$offload_update_post = true;
1688 $post_update = wp_update_post( [ 'ID' => $post_id ] );
1689 self::$offload_update_post = false;
1690 if ( $post_update === 0 ) {
1691 return false;
1692 }
1693 do_action( 'optml_updated_post', $post_id );
1694
1695 return true;
1696 }
1697
1698 /**
1699 * Calculate the number of images in media library and the number of posts/pages.
1700 *
1701 * @param string $action The actions for which to get the number of images.
1702 *
1703 * @return int Number of images.
1704 */
1705 public static function number_of_images_and_pages( $action ) {
1706 $images_args = self::get_images_or_pages_query_args( - 1, $action, true );
1707
1708 $images = new \WP_Query( $images_args );
1709
1710 // With the new mechanism, when offloading images, we don't need to address pages anymore.
1711 // Bail early with the number of images.
1712 if ( $action === 'offload_images' ) {
1713 return $images->post_count;
1714 }
1715
1716 $pages_args = self::get_images_or_pages_query_args( - 1, $action );
1717 $pages = new \WP_Query( $pages_args );
1718
1719 return $pages->post_count + $images->post_count;
1720 }
1721
1722 /**
1723 * Calculate the number of images in media library and the number of posts/pages by IDs.
1724 *
1725 * @param string $action The actions for which to get the number of images.
1726 *
1727 * @return int Number of images.
1728 */
1729 public static function number_of_images_by_ids( $action, $ids ) {
1730 $args = self::get_images_or_pages_query_args( - 1, $action, true );
1731 $args['post__in'] = $ids;
1732 $images = new \WP_Query( $args );
1733
1734 return $images->post_count;
1735 }
1736
1737 /**
1738 * Get pages that contain images by IDs.
1739 *
1740 * @param string $action The actions for which to get the number of images.
1741 * @param array $images Image IDs.
1742 * @param int $batch Batch count.
1743 * @param int $page Page number.
1744 */
1745 public static function get_posts_by_image_ids( $action, $images = [], $batch = 10, $page = 1 ) {
1746 if ( empty( $images ) ) {
1747 return [];
1748 }
1749
1750 $transient_key = 'optml_images_' . md5( serialize( $images ) );
1751 $transient = get_transient( $transient_key );
1752
1753 if ( false !== $transient ) {
1754 return array_slice( $transient, ( $page - 1 ) * $batch, $batch );
1755 }
1756
1757 global $wpdb;
1758
1759 $image_urls = array_map(
1760 function ( $image_id ) {
1761 $meta = wp_get_attachment_metadata( $image_id );
1762 $extension = Optml_Media_Offload::instance()->get_ext( $meta['file'] );
1763
1764 return str_replace( '.' . $extension, '', $meta['file'] );
1765 },
1766 $images
1767 );
1768
1769 // Sanitize the image URLs for use in the SQL query.
1770 $urls = array_map( 'esc_url_raw', $image_urls );
1771
1772 // Initialize an empty string to hold the query.
1773 $query = '';
1774
1775 // Iterate through the array and add each URL to the query.
1776 foreach ( $urls as $index => $url ) {
1777 // If it's the first item, we don't need to add OR to the beginning.
1778 if ( $index === 0 ) {
1779 $query .= $wpdb->prepare( 'post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' );
1780 } else {
1781 $query .= $wpdb->prepare( ' OR post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' );
1782 }
1783 }
1784
1785 // Get all the posts IDs by using LIMIT and offset in a loop.
1786 $ids = [];
1787 $offset = 0;
1788 $limit = $batch;
1789
1790 while ( true ) {
1791 $posts = $wpdb->get_col(
1792 $wpdb->prepare(
1793 "SELECT ID FROM $wpdb->posts WHERE $query LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1794 $limit,
1795 $offset
1796 )
1797 );
1798
1799 if ( empty( $posts ) ) {
1800 break;
1801 }
1802
1803 $ids = array_merge( $ids, $posts );
1804 $offset += $limit;
1805 }
1806
1807 set_transient( $transient_key, $ids, HOUR_IN_SECONDS );
1808
1809 return array_slice( $ids, ( $page - 1 ) * $batch, $batch );
1810 }
1811
1812 /**
1813 * Record process meta,
1814 *
1815 * @param int $count The number of images to process.
1816 *
1817 * @return void
1818 */
1819 public static function record_process_meta( $count ) {
1820 $meta = get_option( 'optml_process_meta', [] );
1821 $meta['count'] = $count;
1822 $meta['remaining'] = $count;
1823 $meta['start_time'] = time();
1824 update_option( 'optml_process_meta', $meta );
1825 }
1826
1827 /**
1828 * Update the process meta count.
1829 *
1830 * @return void
1831 */
1832 public static function decrement_process_meta_remaining() {
1833 $meta = get_option( 'optml_process_meta', [] );
1834
1835 if ( ! isset( $meta['remaining'] ) ) {
1836 return;
1837 }
1838
1839 $meta['remaining'] = $meta['remaining'] - 1;
1840 update_option( 'optml_process_meta', $meta );
1841 }
1842
1843 /**
1844 * Get process meta,
1845 *
1846 * @return array
1847 */
1848 public static function get_process_meta() {
1849 $res = [];
1850 $meta = get_option( 'optml_process_meta', [] );
1851 $res['time_passed'] = isset( $meta['start_time'] ) ? ( time() - $meta['start_time'] ) / 60 : 0;
1852 $res['count'] = isset( $meta['count'] ) ? $meta['count'] : 0;
1853 $res['remaining'] = isset( $meta['remaining'] ) ? $meta['remaining'] : $res['count'];
1854
1855 return $res;
1856 }
1857
1858 /**
1859 * Calculate the number of images in media library and the number of posts/pages.
1860 *
1861 * @param string $action The actions for which to get the number of images.
1862 * @param bool $refresh Whether to refresh the cron or not.
1863 *
1864 * @return array Image count and Cron status.
1865 */
1866 public static function move_images( $action, $refresh ) {
1867 $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status';
1868 $count = 0;
1869 $step = 0;
1870 $batch = apply_filters( 'optimole_offload_batch', 20 ); // Reduce this to smaller if we have memory issues during testing.
1871
1872 $count = Optml_Media_Offload::number_of_images_and_pages( $action );
1873
1874 $possible_batch = ceil( $count / 10 );
1875
1876 if ( $possible_batch < $batch ) {
1877 $batch = $possible_batch;
1878 }
1879
1880 // If batch is less than 10, set it to 10.
1881 if ( $batch < 10 ) {
1882 $batch = 10;
1883 }
1884
1885 $in_progress = self::$instance->settings->get( $option ) !== 'disabled';
1886
1887 if ( $count === 0 ) {
1888 $in_progress = false;
1889 }
1890 $type = 'offload_images' === $action ? 'offload' : 'rollback';
1891 self::$instance->settings->update( 'transfer_status', $action );
1892 if ( false === $refresh ) {
1893 // We check also the alternative action to avoid doing both in the same time and disable the running one.
1894 $in_progress_b = self::$instance->settings->get( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status' ) !== 'disabled';
1895 // We do this only if there is a mass action in progress, not individual ones.
1896 if ( $in_progress_b ) {
1897 // We stop the oposite action from going any further.
1898 self::$instance->settings->update( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status', 'disabled' );
1899 }
1900 self::$instance->settings->update( 'offload_limit_reached', 'disabled' );
1901 self::record_process_meta( $count );
1902
1903 self::$instance->settings->update( $option, $in_progress ? 'enabled' : 'disabled' );
1904 self::$instance->logger->add_log( $type, Optml_Logger::LOG_SEPARATOR );
1905 self::$instance->logger->add_log( $type, 'Started with a total count of ' . intval( $count ) . '.' );
1906
1907 if ( $in_progress !== true ) {
1908 return [
1909 'count' => $count,
1910 'status' => $in_progress,
1911 'action' => $type,
1912 ];
1913 }
1914
1915 // We acquire a lock to prevent multiple workers from running the same action concurrently.
1916 $lock_token = self::acquire_transfer_lock( $action );
1917
1918 if ( false !== $lock_token ) {
1919 $total = ceil( $count / $batch );
1920 self::schedule_action(
1921 time(),
1922 'optml_start_processing_images',
1923 [
1924 $action,
1925 $batch,
1926 1,
1927 $total,
1928 $step,
1929 $lock_token,
1930 ]
1931 );
1932 }
1933 }
1934
1935 $response = [
1936 'count' => $count,
1937 'action' => $type,
1938 ];
1939
1940 if ( $type === 'offload' ) {
1941 $offload_limit_reached = self::$instance->settings->is_offload_limit_reached();
1942 if ( $offload_limit_reached ) {
1943 $in_progress = false;
1944 self::$instance->settings->update( $option, 'disabled' );
1945 }
1946
1947 $response['reached_limit'] = self::$instance->settings->is_offload_limit_reached();
1948 $response['offload_limit'] = self::$instance->settings->get( 'offload_limit' );
1949 }
1950
1951 $response['status'] = $in_progress;
1952
1953 return $response;
1954 }
1955
1956 /**
1957 * Schedule an action.
1958 *
1959 * @param int $time The time to schedule the action.
1960 * @param string $hook The hook to schedule.
1961 * @param array $args The arguments to pass to the hook.
1962 *
1963 * @return mixed
1964 */
1965 public static function schedule_action( $time, $hook, $args ) {
1966 // We use AS if available to avoid issues with WP Cron.
1967 if ( function_exists( 'as_schedule_single_action' ) ) {
1968 return as_schedule_single_action( $time, $hook, $args );
1969 } else {
1970 return wp_schedule_single_event( $time, $hook, $args );
1971 }
1972 }
1973
1974 /**
1975 * Check if an action hook is scheduled.
1976 *
1977 * @param string $hook The hook to check.
1978 *
1979 * @return bool
1980 */
1981 public static function is_scheduled( $hook ) {
1982 if ( function_exists( 'as_has_scheduled_action' ) ) {
1983 return as_has_scheduled_action( $hook );
1984 } elseif ( function_exists( 'as_next_scheduled_action' ) ) {
1985 // For older versions of AS.
1986 return as_next_scheduled_action( $hook ) !== false;
1987 } else {
1988 return wp_next_scheduled( $hook ) !== false;
1989 }
1990 }
1991
1992 /**
1993 * Attempt to acquire the transfer lock for a given action.
1994 *
1995 * @param string $action The transfer action ('offload_images'|'rollback_images').
1996 *
1997 * @return string|false The lock token on success, false if another worker already holds the lock.
1998 */
1999 public static function acquire_transfer_lock( $action ) {
2000 $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2001 if ( false !== $lock ) {
2002 return false;
2003 }
2004
2005 $token = wp_generate_uuid4();
2006
2007 set_transient(
2008 self::TRANSFER_LOCK_TRANSIENT,
2009 [
2010 'token' => $token,
2011 'action' => $action,
2012 ],
2013 self::TRANSFER_LOCK_TTL
2014 );
2015
2016 return $token;
2017 }
2018
2019 /**
2020 * Renew the transfer lock if we still own it, extending its expiration.
2021 *
2022 * @param string $token The lock token this worker was given when it started the chain.
2023 * @param string $action The transfer action currently being processed.
2024 *
2025 * @return bool True if we still own the lock and renewed it, false if ownership was lost.
2026 */
2027 public static function renew_transfer_lock( $token, $action ) {
2028 $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2029
2030 if ( ! is_array( $lock ) || ! isset( $lock['token'] ) || $lock['token'] !== $token ) {
2031 return false;
2032 }
2033
2034 set_transient(
2035 self::TRANSFER_LOCK_TRANSIENT,
2036 [
2037 'token' => $token,
2038 'action' => $action,
2039 ],
2040 self::TRANSFER_LOCK_TTL
2041 );
2042
2043 return true;
2044 }
2045
2046 /**
2047 * Release the transfer lock if we still own it, allowing another worker to acquire it.
2048 *
2049 * @param string $token The lock token to release.
2050 *
2051 * @return void
2052 */
2053 public static function release_transfer_lock( $token ) {
2054 $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2055
2056 if ( ! is_array( $lock ) || ! isset( $lock['token'] ) || $lock['token'] !== $token ) {
2057 return;
2058 }
2059
2060 delete_transient( self::TRANSFER_LOCK_TRANSIENT );
2061 }
2062
2063 /**
2064 * Start Processing Images by IDs
2065 *
2066 * @param string $action The action for which to get the number of images.
2067 * @param int $id The images to process.
2068 *
2069 * @throws Exception If there is an error.
2070 * @return void
2071 */
2072 public function move_single_image( $action, $id ) {
2073 set_time_limit( 0 );
2074
2075 // Only use the legacy offloaded attachments to query the pages that need to be updated.
2076 // We can be confident that these IDs are already marked as offloaded.
2077 $legacy_offloaded = ! $this->is_new_offloaded_attachment( $id );
2078 $page_in = [];
2079 if ( $legacy_offloaded ) {
2080 $page_in = $action === 'offload_images' ? [] : Optml_Media_Offload::get_posts_by_image_ids( $action, [ $id ] );
2081 }
2082 // This will be 0 in the case of offloading now.
2083 if ( $action === 'rollback_images' && 0 !== count( $page_in ) ) {
2084 $page = 0;
2085 do {
2086 $to_update = Optml_Media_Offload::instance()->update_content( $page, $action, 100, $page_in );
2087 if ( isset( $to_update['page'] ) ) {
2088 if ( isset( $to_update['imagesToUpdate'] ) && count( $to_update['imagesToUpdate'] ) ) {
2089 foreach ( $to_update['imagesToUpdate'] as $post_id => $images ) {
2090 $images = array_intersect( $images, [ $id ] );
2091 if ( empty( $images ) ) {
2092 continue;
2093 }
2094 Optml_Media_Offload::instance()->rollback_and_update_images( $images );
2095 Optml_Media_Offload::instance()->update_page( $post_id );
2096 }
2097 }
2098 }
2099 $page = $page + 1;
2100 } while ( ! empty( $to_update['imagesToUpdate'] ) );
2101
2102 } else {
2103 $action === 'rollback_images' ?
2104 Optml_Media_Offload::instance()->rollback_images( 1, [ $id ] ) :
2105 Optml_Media_Offload::instance()->upload_images( 1, [ $id ] );
2106 }
2107 if ( empty( $page_in ) && $legacy_offloaded === false ) {
2108 $meta = self::get_process_meta();
2109 self::$instance->logger->add_log( $action, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' );
2110 return;
2111 }
2112 }
2113
2114 /**
2115 * Start Processing Images
2116 *
2117 * @param string $action The action for which to get the number of images.
2118 * @param int $batch The batch of images to process.
2119 * @param int $page The page of images to process.
2120 * @param int $total The total number of pages.
2121 * @param int $step The current step.
2122 * @param string $lock_token The transfer lock token owned by this processing chain.
2123 *
2124 * @return void
2125 */
2126 public function start_processing_images( $action, $batch, $page, $total, $step, $lock_token = '' ) {
2127 $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status';
2128 $type = 'offload_images' === $action ? 'offload' : 'rollback';
2129
2130 if ( self::$instance->settings->get( $option ) === 'disabled' ) {
2131 self::release_transfer_lock( $lock_token );
2132 return;
2133 }
2134
2135 // If we don't own the lock anymore, stop processing.
2136 if ( ! self::renew_transfer_lock( $lock_token, $action ) ) {
2137 return;
2138 }
2139
2140 if ( $step > $total || 0 === $total ) {
2141 $meta = self::get_process_meta();
2142 self::$instance->logger->add_log( $type, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' );
2143
2144 self::$instance->settings->update( $option, 'disabled' );
2145
2146 self::$instance->settings->update( 'show_offload_finish_notice', $type );
2147
2148 // Transfer completed successfully: release the lock.
2149 self::release_transfer_lock( $lock_token );
2150
2151 return;
2152 }
2153
2154 set_time_limit( 0 );
2155
2156 try {
2157 $posts_to_update = $action === 'offload_images' ? [] : Optml_Media_Offload::instance()->update_content( $page, $action, $batch );
2158
2159 // Kept for backward compatibility with old offloading mechanism where pages were modified.
2160 if ( $action === 'rollback_images' && isset( $posts_to_update['page'] ) && $posts_to_update['page'] > $page ) {
2161 $page = $posts_to_update['page'];
2162 if ( isset( $posts_to_update['imagesToUpdate'] ) && count( $posts_to_update['imagesToUpdate'] ) ) {
2163 foreach ( $posts_to_update['imagesToUpdate'] as $post_id => $images ) {
2164 Optml_Media_Offload::instance()->rollback_and_update_images( $images );
2165 Optml_Media_Offload::instance()->update_page( $post_id );
2166 }
2167 }
2168 } else {
2169 $action === 'rollback_images' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch );
2170 }
2171
2172 $step = $step + 1;
2173
2174 self::schedule_action(
2175 time(),
2176 'optml_start_processing_images',
2177 [
2178 $action,
2179 $batch,
2180 $page,
2181 $total,
2182 $step,
2183 $lock_token,
2184 ]
2185 );
2186 } catch ( Exception $e ) {
2187 // Reschedule the cron to run again after a delay. Sometimes memory limit is exausted.
2188 // This is a retryable error, so the lock is kept rather than released.
2189 $delay_in_seconds = 10;
2190 self::$instance->logger->add_log( $type, $e->getMessage() );
2191
2192 self::schedule_action(
2193 time() + $delay_in_seconds,
2194 'optml_start_processing_images',
2195 [
2196 $action,
2197 $batch,
2198 $page,
2199 $total,
2200 $step,
2201 $lock_token,
2202 ]
2203 );
2204 }
2205 }
2206
2207 /**
2208 * Alter attachment image src for offloaded images.
2209 *
2210 * @param array|false $image {
2211 * Array of image data.
2212 *
2213 * @type string $0 Image source URL.
2214 * @type int $1 Image width in pixels.
2215 * @type int $2 Image height in pixels.
2216 * @type bool $3 Whether the image is a resized image.
2217 * }
2218 *
2219 * @param int $attachment_id attachment id.
2220 * @param string|int[] $size image size.
2221 * @param bool $icon Whether the image should be treated as an icon.
2222 *
2223 * @return array $image.
2224 */
2225 public function alter_attachment_image_src( $image, $attachment_id, $size, $icon ) {
2226 if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
2227 return $image;
2228 }
2229 if ( isset( $image[0] ) ) {
2230 $url = $image[0];
2231 } else {
2232 $url = get_post( $attachment_id );
2233 $url = $url->guid;
2234 }
2235 $metadata = wp_get_attachment_metadata( $attachment_id );
2236
2237 // Use the original size if the requested size is full.
2238 if ( $size === 'full' || $this->is_attachment_edit_page( $attachment_id ) ) {
2239 $image_url = $this->get_new_offloaded_attachment_url(
2240 $url,
2241 $attachment_id,
2242 [
2243 'width' => $metadata['width'],
2244 'height' => $metadata['height'],
2245 'attachment_id' => $attachment_id,
2246 ],
2247 $metadata
2248 );
2249
2250 return [
2251 $image_url,
2252 $metadata['width'],
2253 $metadata['height'],
2254 false,
2255 ];
2256 }
2257
2258 if ( doing_action( 'wp_insert_post_data' ) && wp_attachment_is( 'video', $attachment_id ) ) {
2259 return $image;
2260 }
2261 $sizes = $this->size_to_dimension( $size, $metadata );
2262 $image_url = $this->get_new_offloaded_attachment_url(
2263 $url,
2264 $attachment_id,
2265 [
2266 'width' => $sizes['width'],
2267 'height' => $sizes['height'],
2268 'resize' => $sizes['resize'] ?? [],
2269 'attachment_id' => $attachment_id,
2270 ],
2271 $metadata
2272 );
2273
2274 return [
2275 $image_url,
2276 $sizes['width'],
2277 $sizes['height'],
2278 $size === 'full', // @phpstan-ignore-line
2279 ];
2280 }
2281
2282 /**
2283 * Needed for image sizes inside the editor.
2284 *
2285 * @param array $response Array of prepared attachment data. @see wp_prepare_attachment_for_js().
2286 * @param WP_Post $attachment Attachment object.
2287 * @param array|false $meta Array of attachment meta data, or false if there is none.
2288 *
2289 * @return array
2290 */
2291 public function alter_attachment_for_js( $response, $attachment, $meta ) {
2292 if ( ! $this->is_new_offloaded_attachment( $attachment->ID ) ) {
2293 return $response;
2294 }
2295
2296 $meta = [];
2297 if ( isset( $response['width'] ) ) {
2298 $meta['width'] = $response['width'];
2299 }
2300 if ( isset( $response['height'] ) ) {
2301 $meta['height'] = $response['height'];
2302 }
2303 $sizes = Optml_App_Replacer::image_sizes();
2304
2305 foreach ( $sizes as $size => $args ) {
2306 if ( isset( $response['sizes'][ $size ] ) ) {
2307 continue;
2308 }
2309 $args = $this->size_to_dimension( $size, $meta );
2310 $response['sizes'][ $size ] = array_merge(
2311 $args,
2312 [
2313 'url' => $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $args ),
2314 'orientation' => ( $args['height'] > $args['width'] ) ? 'portrait' : 'landscape',
2315 ]
2316 );
2317 }
2318 $response['url'] = $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $meta );
2319
2320 return $response;
2321 }
2322
2323 /**
2324 * Alter attachment metadata.
2325 *
2326 * @param array $metadata The attachment metadata.
2327 * @param int $id The attachment ID.
2328 *
2329 * @return array
2330 */
2331 public function alter_attachment_metadata( $metadata, $id ) {
2332 if ( ! $this->is_new_offloaded_attachment( $id ) ) {
2333 return $metadata;
2334 }
2335
2336 return $this->get_altered_metadata_for_remote_images( $metadata, $id );
2337 }
2338
2339 /**
2340 * Get offloaded image attachment URL for new offloads.
2341 *
2342 * @param string $url The initial attachment URL.
2343 * @param int $attachment_id The attachment ID.
2344 * @param array $args The additional arguments.
2345 * - width: The width of the image.
2346 * - height: The height of the image.
2347 * - crop: Whether to crop the image.
2348 * @param array|null $attachment_metadata The attachment metadata.
2349 * @return string
2350 */
2351 private function get_new_offloaded_attachment_url( $url, $attachment_id, $args = [], $attachment_metadata = null ) {
2352 $process_flag = self::KEYS['not_processed_flag'] . $attachment_id;
2353
2354 // Image might have already passed through this filter.
2355 if ( strpos( $url, $process_flag ) !== false ) {
2356 return $url;
2357 }
2358 if ( $attachment_metadata === null ) {
2359 $meta = wp_get_attachment_metadata( $attachment_id );
2360 } else {
2361 $meta = $attachment_metadata;
2362 }
2363 if ( ! isset( $meta['file'] ) ) {
2364 return $url;
2365 }
2366
2367 $default_args = [
2368 'width' => 'auto',
2369 'height' => 'auto',
2370 'resize' => apply_filters( 'optml_default_crop', [] ),
2371 ];
2372
2373 $args = wp_parse_args( $args, $default_args );
2374 // If this is not cropped, we constrain the dimensions to the original image.
2375 if ( empty( $args['resize'] ) && ! in_array( 'auto', [ $args['width'], $args['height'] ], true ) ) {
2376 $dimensions = wp_constrain_dimensions( $meta['width'], $meta['height'], $args['width'], $args['height'] );
2377 $args['width'] = $dimensions[0];
2378 $args['height'] = $dimensions[1];
2379 }
2380
2381 $file = $meta['file'];
2382 if ( self::is_uploaded_image( $file ) ) {
2383 $optimized_url = $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] );
2384
2385 return strpos( $optimized_url, $process_flag ) === false ? str_replace( '/' . ltrim( $file, '/' ), '/' . $process_flag . $file, $optimized_url ) : $optimized_url;
2386 } else {
2387 // this is for the users that already offloaded the images before the other fixes
2388 $local_file = get_attached_file( $attachment_id );
2389 if ( ! file_exists( $local_file ) ) {
2390 $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
2391 if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
2392 foreach ( $duplicated_images as $id ) {
2393 if ( ! empty( $id ) ) {
2394 $duplicated_meta = wp_get_attachment_metadata( $id );
2395 if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
2396 return $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] );
2397 }
2398 }
2399 }
2400 }
2401 }
2402 }
2403 return $url;
2404 }
2405
2406 /**
2407 * Replace the URLs in the editor content with the offloaded ones.
2408 *
2409 * @param string $content The incoming content.
2410 *
2411 * @return string
2412 */
2413 public function replace_urls_in_editor_content( $content ) {
2414 $raw_extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content );
2415
2416 if ( empty( $raw_extracted ) ) {
2417 return $content;
2418 }
2419
2420 $to_replace = [];
2421 foreach ( $raw_extracted as $url ) {
2422 $attachment = $this->get_local_attachement_id_from_url( $url );
2423
2424 // No local attachment.
2425 if ( $attachment['attachment_id'] === 0 ) {
2426 if ( $this->can_replace_url( $url ) ) {
2427 $to_replace[ $url ] = $this->get_optimized_image_url( $url, 'auto', 'auto' );
2428 }
2429 continue;
2430 }
2431
2432 $attachment_id = $attachment['attachment_id'];
2433
2434 // Not offloaded.
2435 if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
2436 continue;
2437 }
2438
2439 // Get W/H from url.
2440 $size = $this->parse_dimensions_from_filename( $url );
2441 $width = $size[0] !== false ? $size[0] : 'auto';
2442 $height = $size[1] !== false ? $size[1] : 'auto';
2443
2444 // Handle resize.
2445 $sizes2crop = self::size_to_crop();
2446 $resize = apply_filters( 'optml_default_crop', [] );
2447 $sizes = image_get_intermediate_size( $attachment_id, $size );
2448 if ( false !== $sizes ) {
2449 if ( isset( $sizes2crop[ $width . $height ] ) ) {
2450 $resize = $this->to_optml_crop( $sizes2crop[ $width . $height ] );
2451 }
2452 }
2453
2454 // Build the optimized URL.
2455 $optimized_url = $this->get_optimized_image_url( self::KEYS['not_processed_flag'] . $attachment_id . '/' . ltrim( $this->get_offloaded_attachment_url( $attachment_id, $url ), '/' ), $width, $height, $resize );
2456
2457 // Drop any image size from the URL.
2458 $optimized_url = str_replace( '-' . $width . 'x' . $height, '', $optimized_url );
2459
2460 $to_replace[ $url ] = $optimized_url;
2461 }
2462
2463 return str_replace( array_keys( $to_replace ), array_values( $to_replace ), $content );
2464 }
2465
2466 /**
2467 * Replaces the post content URLs to use Offloaded ones on editor fetch.
2468 *
2469 * @param \WP_REST_Response $response The response object.
2470 * @param \WP_Post $post The post object.
2471 * @param \WP_REST_Request $request The request object.
2472 *
2473 * @return \WP_REST_Response
2474 */
2475 public function pre_filter_rest_content( \WP_REST_Response $response, \WP_Post $post, \WP_REST_Request $request ) {
2476 $context = $request->get_param( 'context' );
2477
2478 if ( $context !== 'edit' ) {
2479 return $response;
2480 }
2481
2482 $data = $response->get_data();
2483
2484 // Actually replace all URLs.
2485 $data['content']['raw'] = $this->replace_urls_in_editor_content( $data['content']['raw'] );
2486
2487 $response->set_data( $data );
2488
2489 return $response;
2490 }
2491
2492 /**
2493 * Legacy function to be used for WordPress versions under 6.0.0.
2494 *
2495 * @param array $post_data Slashed, sanitized, processed post data.
2496 * @param array $postarr Slashed sanitized post data.
2497 * @param array $unsanitized_postarr Un-sanitized post data.
2498 *
2499 * @return array
2500 */
2501 public function legacy_filter_saved_data( $post_data, $postarr, $unsanitized_postarr ) {
2502 return $this->filter_saved_data( $post_data, $postarr, $unsanitized_postarr, true );
2503 }
2504
2505 /**
2506 * Filter post content to use local attachments when saving offloaded images.
2507 *
2508 * @param array $post_data Slashed, sanitized, processed post data.
2509 * @param array $postarr Slashed sanitized post data.
2510 * @param array $unsanitized_postarr Un-sanitized post data.
2511 * @param bool $update Whether this is an existing post being updated or not.
2512 *
2513 * @return array
2514 */
2515 public function filter_saved_data( $post_data, $postarr, $unsanitized_postarr, $update ) {
2516 if ( $postarr['post_status'] === 'trash' ) {
2517 return $post_data;
2518 }
2519
2520 $content = $post_data['post_content'];
2521
2522 $extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content );
2523 $replace = [];
2524
2525 foreach ( $extracted as $idx => $url ) {
2526 $id = self::get_attachment_id_from_url( $url );
2527
2528 if ( $id === false ) {
2529 continue;
2530 }
2531
2532 $id = (int) $id;
2533
2534 if ( $this->is_legacy_offloaded_attachment( $id ) ) {
2535 continue;
2536 }
2537
2538 $original = self::get_original_url( $id );
2539
2540 if ( $original === false ) {
2541 continue;
2542 }
2543
2544 $replace[ $url ] = $original;
2545
2546 $size = $this->parse_dimension_from_optimized_url( $url );
2547
2548 if ( $size[0] === false || $size[1] === false ) {
2549 continue;
2550 }
2551 if ( $size[0] === 'auto' || $size[1] === 'auto' ) {
2552 continue;
2553 }
2554
2555 $extension = $this->get_ext( $url );
2556 $metadata = wp_get_attachment_metadata( $id );
2557
2558 // Is this the full URL.
2559 if ( $metadata['width'] === (int) $size[0] && $metadata['height'] === (int) $size[1] ) {
2560 continue;
2561 }
2562
2563 $size_crop_map = self::size_to_crop();
2564
2565 $crop = false;
2566
2567 if ( isset( $size_crop_map[ $size[0] . $size[1] ] ) ) {
2568 $crop = $size_crop_map[ $size[0] . $size[1] ];
2569 }
2570
2571 if ( $crop ) {
2572 $width = $size[0];
2573 $height = $size[1];
2574 } else {
2575 // In case of an image size, we need to calculate the new dimensions for the proper file path.
2576 $constrained = wp_constrain_dimensions( $metadata['width'], $metadata['height'], (int) $size[0], (int) $size[1] );
2577
2578 $width = $constrained[0];
2579 $height = $constrained[1];
2580 }
2581 $replace[ $url ] = $this->maybe_strip_scaled( $replace[ $url ] );
2582
2583 $suffix = sprintf( '-%sx%s.%s', $width, $height, $extension );
2584
2585 $replace[ $url ] = str_replace( '.' . $extension, $suffix, $replace[ $url ] );
2586 }
2587
2588 $post_data['post_content'] = str_replace( array_keys( $replace ), array_values( $replace ), $content );
2589
2590 return $post_data;
2591 }
2592
2593 /**
2594 * Alter the image size for the image widget.
2595 *
2596 * @param string $html the attachment image HTML string.
2597 * @param array $settings Control settings.
2598 * @param string $image_size_key Optional. Settings key for image size.
2599 * Default is `image`.
2600 * @param string $image_key Optional. Settings key for image. Default
2601 * is null. If not defined uses image size key
2602 * as the image key.
2603 *
2604 * @return string
2605 */
2606 public function alter_elementor_image_size( $html, $settings, $image_size_key, $image_key ) {
2607 if ( ! isset( $settings['image'] ) ) {
2608 return $html;
2609 }
2610
2611 $image = $settings['image'];
2612
2613 if ( ! isset( $image['id'] ) ) {
2614 return $html;
2615 }
2616
2617 if ( ! $this->is_new_offloaded_attachment( $image['id'] ) ) {
2618 return $html;
2619 }
2620
2621 if ( ! isset( $settings['image_size'] ) ) {
2622 return $html;
2623 }
2624
2625 if ( $settings['image_size'] === 'custom' ) {
2626 if ( ! isset( $settings['image_custom_dimension'] ) ) {
2627 return $html;
2628 }
2629
2630 $custom_dimensions = $settings['image_custom_dimension'];
2631
2632 if ( ! isset( $custom_dimensions['width'] ) || ! isset( $custom_dimensions['height'] ) ) {
2633 return $html;
2634 }
2635
2636 $new_args = [
2637 'width' => $custom_dimensions['width'],
2638 'height' => $custom_dimensions['height'],
2639 'resize' => $this->to_optml_crop( true ),
2640 ];
2641 $new_url = $this->get_new_offloaded_attachment_url( $image['url'], $image['id'], $new_args );
2642
2643 return str_replace( $image['url'], $new_url, $html );
2644 }
2645
2646 return $html;
2647 }
2648
2649 /**
2650 * Adds new actions for new offloads.
2651 *
2652 * @return void
2653 */
2654 public function add_new_actions() {
2655 add_filter( 'wp_prepare_attachment_for_js', [ self::$instance, 'alter_attachment_for_js' ], 999, 3 );
2656 add_filter( 'wp_get_attachment_metadata', [ self::$instance, 'alter_attachment_metadata' ], 10, 2 );
2657 add_filter( 'wp_get_attachment_image_src', [ self::$instance, 'alter_attachment_image_src' ], 10, 4 );
2658
2659 // Needed for rendering beaver builder css properly.
2660 add_filter( 'fl_builder_render_css', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 );
2661
2662 // Filter saved data on insert to use local attachments.
2663 // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
2664 $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
2665 if ( $below_6_0_0 ) {
2666 add_filter( 'wp_insert_post_data', [ self::$instance, 'legacy_filter_saved_data' ], 10, 3 );
2667 } else {
2668 add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_saved_data' ], 10, 4 );
2669 }
2670
2671 // Filter loaded data in the editors to use local attachments.
2672 add_filter( 'content_edit_pre', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 );
2673
2674 add_action(
2675 'init',
2676 function () {
2677 $types = get_post_types_by_support( 'editor' );
2678
2679 foreach ( $types as $type ) {
2680
2681 $post_type = get_post_type_object( $type );
2682
2683 if ( property_exists( $post_type, 'show_in_rest' ) && true === $post_type->show_in_rest ) {
2684 add_filter( 'rest_prepare_' . $type, [ self::$instance, 'pre_filter_rest_content' ], 10, 3 );
2685 }
2686 }
2687 },
2688 PHP_INT_MAX
2689 );
2690
2691 add_filter( 'get_attached_file', [ $this, 'alter_attached_file_response' ], 10, 2 );
2692 add_filter(
2693 'elementor/image_size/get_attachment_image_html',
2694 [
2695 $this,
2696 'alter_elementor_image_size',
2697 ],
2698 10,
2699 4
2700 );
2701 }
2702
2703 /**
2704 * Elementor checks if the file exists before requesting a specific image size.
2705 *
2706 * Needed because otherwise there won't be any width/height on the `img` tags, breaking lazyload.
2707 *
2708 * Also needed because some
2709 *
2710 * @param string $file The file path.
2711 * @param int $id The attachment ID.
2712 *
2713 * @return bool|string
2714 */
2715 public function alter_attached_file_response( $file, $id ) {
2716 if ( ! $this->is_new_offloaded_attachment( $id ) ) {
2717 return $file;
2718 }
2719
2720 $metadata = wp_get_attachment_metadata( $id );
2721
2722 if ( isset( $metadata['file'] ) ) {
2723 $uploads = wp_get_upload_dir();
2724
2725 return $uploads['basedir'] . '/' . $metadata['file'];
2726 }
2727
2728 return true;
2729 }
2730
2731 /**
2732 * Maybe strip the `-scaled` from the URL.
2733 *
2734 * @param string $url The url.
2735 *
2736 * @return string
2737 */
2738 public function maybe_strip_scaled( $url ) {
2739 $ext = $this->get_ext( $url );
2740
2741 return str_replace( '-scaled.' . $ext, '.' . $ext, $url );
2742 }
2743
2744 /**
2745 * Is it a PHPUnit test run.
2746 *
2747 * @return bool
2748 */
2749 public static function is_phpunit_test() {
2750 return defined( 'OPTML_PHPUNIT_TESTING' ) && OPTML_PHPUNIT_TESTING === true;
2751 }
2752
2753 /**
2754 * Get offloaded image attachment URL based on the given attachment ID and URL.
2755 *
2756 * @param mixed $attachment_id The attachment ID.
2757 * @param string $url The attachment URL.
2758 *
2759 * @return string
2760 */
2761 private function get_offloaded_attachment_url( $attachment_id, $url ) {
2762 if ( ! $this->settings->is_offload_enabled() || ! is_numeric( $attachment_id ) ) {
2763 return $url;
2764 } elseif ( empty( $attachment_id ) && strpos( $url, self::KEYS['not_processed_flag'] ) !== false ) {
2765 $attachment_id = (int) self::get_attachment_id_from_url( $url );
2766 } elseif ( empty( $attachment_id ) ) {
2767 $attachment_id = $this->attachment_url_to_post_id( $url );
2768 }
2769
2770 if ( $attachment_id > 0 || ! empty( get_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ) ) ) {
2771 $url = wp_get_attachment_metadata( $attachment_id )['file'];
2772 }
2773
2774 return $url;
2775 }
2776
2777 /**
2778 * Cleanup the offload errors meta.
2779 *
2780 * @param string $meta_key The meta key to delete. Defaults to the offload error key.
2781 *
2782 * @return int|bool Number of rows affected/selected or false on error.
2783 */
2784 public static function clear_offload_errors_meta( $meta_key = '' ) {
2785 global $wpdb;
2786
2787 if ( empty( $meta_key ) ) {
2788 $meta_key = self::META_KEYS['offload_error'];
2789 }
2790
2791 // Collect the affected attachments before the bulk delete so their object
2792 // caches can be invalidated. A raw DELETE bypasses the meta/query caches,
2793 // which would otherwise leave stale WP_Query results for subsequent queries.
2794 $post_ids = $wpdb->get_col(
2795 $wpdb->prepare(
2796 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s",
2797 $meta_key
2798 )
2799 );
2800
2801 $result = $wpdb->query(
2802 $wpdb->prepare(
2803 "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
2804 $meta_key
2805 )
2806 );
2807
2808 foreach ( $post_ids as $post_id ) {
2809 wp_cache_delete( (int) $post_id, 'post_meta' );
2810 }
2811
2812 // Bump the posts last_changed so cached WP_Query results (which are keyed
2813 // on it) are recomputed on the next query. The raw DELETE above does not
2814 // touch the object cache, so without this the retried rollback/offload
2815 // query could return a stale set that still excludes the cleared posts.
2816 wp_cache_set( 'last_changed', microtime(), 'posts' );
2817
2818 return $result;
2819 }
2820
2821 /**
2822 * Cleanup the rollback errors meta.
2823 *
2824 * Used when the user retries the rollback process so previously errored
2825 * attachments are considered again for restore.
2826 *
2827 * @return int|bool Number of rows affected/selected or false on error.
2828 */
2829 public static function clear_rollback_errors_meta() {
2830 return self::clear_offload_errors_meta( self::META_KEYS['rollback_error'] );
2831 }
2832 }
2833