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

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

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