PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 3.12.0
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v3.12.0
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 3.12.0, at inc/media_offload.php

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