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

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