WP_Post $post_before Post after the update. * * @return void * @uses action:post_updated */ public function update_offload_meta( $post_ID, $post_after, $post_before ) { if ( self::$offload_update_post === true ) { return; } if ( get_post_type( $post_ID ) === 'attachment' ) { return; } // revisions are skipped inside the function no need to check them before delete_post_meta( $post_ID, self::POST_ROLLBACK_FLAG ); } /** * Get image size name from width and meta. * * @param array $sizes Image sizes . * @param integer $width Size width. * @param string $filename Image filename. * * @return null|string|array */ public static function get_image_size_from_width( $sizes, $width, $filename, $just_name = true ) { foreach ( $sizes as $name => $size ) { if ( $width === absint( $size['width'] ) && $size['file'] === $filename ) { return $just_name ? $name : array_merge( $size, [ 'name' => $name ] ); } } return null; } /** * Replace image URLs in the srcset attributes. * * @param array $sources Array of image sources. * @param array $size_array Array of width and height values in pixels (in that order). * @param string $image_src The 'src' of the image. * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'. * @param int $attachment_id Image attachment ID. * * @return array */ public function calculate_image_srcset( $sources, $size_array, $image_src, $image_meta, $attachment_id ) { if ( ! is_array( $sources ) ) { return $sources; } if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) { if ( ! Optml_Media_Offload::is_uploaded_image( $image_src ) || ! isset( $image_meta['file'] ) || ! Optml_Media_Offload::is_uploaded_image( $image_meta['file'] ) ) { return $sources; } foreach ( $sources as $width => $source ) { $filename = wp_basename( $image_meta['file'] ); $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename ); $optimized_url = wp_get_attachment_image_src( $attachment_id, $size ); if ( false === $optimized_url ) { continue; } $sources[ $width ]['url'] = $optimized_url[0]; } return $sources; } if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { return $sources; } $requested_width = $size_array[0]; $requested_height = $size_array[1]; if ( $requested_height < 1 || $requested_width < 1 ) { return $sources; } $requested_ratio = $requested_width / $requested_height; $image_sizes = $this->get_all_image_sizes(); $crop = false; // Loop through image sizes to make sure we're using the right cropping. foreach ( $image_sizes as $size_name => $args ) { if ( $args['width'] !== $requested_width && $args['height'] !== $requested_height ) { continue; } if ( isset( $args['crop'] ) ) { $crop = (bool) $args['crop']; } } foreach ( $sources as $width => $source ) { $filename = ( $image_meta['file'] ); $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename, false ); if ( $size === null || ! isset( $size['name'] ) ) { unset( $sources[ $width ] ); continue; } if ( ! isset( $image_sizes[ $size['name'] ] ) || (bool) $image_sizes[ $size['name'] ]['crop'] !== $crop ) { unset( $sources[ $width ] ); continue; } // Some image sizes might have 0 values for width or height. if ( $size['width'] < 1 || $size['height'] < 1 ) { unset( $sources[ $width ] ); continue; } $size_ratio = $size['width'] / $size['height']; // We need a srcset with the same aspect ratio. // Otherwise, we'll display different images on different devices. if ( $requested_ratio !== $size_ratio ) { unset( $sources[ $width ] ); continue; } $optimized_url = wp_get_attachment_image_src( $attachment_id, $size['name'] ); if ( false === $optimized_url ) { unset( $sources[ $width ] ); continue; } $sources[ $width ]['url'] = $optimized_url[0]; } // Add the requested size to the srcset. $sources[ $requested_width ] = [ 'url' => $image_src, 'descriptor' => 'w', 'value' => $requested_width, ]; return $sources; } /** * Check if the image is stored on our servers or not. * * @param string $src Image src or url. * * @return bool Whether image is upload or not. */ public static function is_not_processed_image( $src ) { return strpos( $src, self::KEYS['not_processed_flag'] ) !== false; } /** * Check if the image is stored on our servers or not. * * @param string $src Image src or url. * * @return bool Whether image is upload or not. */ public static function is_uploaded_image( $src ) { return strpos( $src, '/' . self::KEYS['uploaded_flag'] ) !== false; } /** * Get the attachment ID from the image tag. * * @param string $image Image tag. * * @return int|false */ public function get_id_from_tag( $image ) { $attachment_id = false; if ( preg_match( '#class=["|\']?[^"\']*(wp-image-|wp-video-)([\d]+)[^"\']*["|\']?#i', $image, $found ) ) { $attachment_id = intval( $found[2] ); } return $attachment_id; } /** * Get attachment id from url * * @param string $url The optimized url . * * @return false|mixed The attachment id . */ public static function get_attachment_id_from_url( $url ) { preg_match( '/\/' . Optml_Media_Offload::KEYS['not_processed_flag'] . '([^\/]*)\//', $url, $attachment_id ); return isset( $attachment_id[1] ) ? $attachment_id[1] : false; } /** * Get attachment id from local url * * @param string $url The url to look for. * * @return array The attachment id and the size from the url. */ public function get_local_attachement_id_from_url( $url ) { $size = 'full'; $found_size = $this->parse_dimensions_from_filename( $url ); $url = $this->add_schema( $url ); if ( $found_size[0] !== false && $found_size[1] !== false ) { $size = $found_size; } $url = $this->add_schema( $url ); $attachment_id = $this->attachment_url_to_post_id( $url ); return [ 'attachment_id' => $attachment_id, 'size' => $size ]; } /** * Filter out the urls that are saved to our servers when saving to the DB. * * @param array $data The post data array to save. * * @return array * @uses filter:wp_insert_post_data */ public function filter_uploaded_images( $data ) { $content = trim( wp_unslash( $data['post_content'] ) ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'content to update' ); do_action( 'optml_log', $content ); } $images = Optml_Manager::instance()->extract_urls_from_content( $content ); if ( ! isset( $images[0] ) ) { return $data; } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'images to update' ); do_action( 'optml_log', $images ); } foreach ( $images as $url ) { $is_original_uploaded = self::is_uploaded_image( $url ); $attachment_id = false; $size = 'thumbnail'; if ( $is_original_uploaded ) { $found_size = $this->parse_dimension_from_optimized_url( $url ); if ( $found_size[0] !== 'auto' && $found_size[1] !== 'auto' ) { $size = $found_size; } $attachment_id = self::get_attachment_id_from_url( $url ); } else { $id_and_size = $this->get_local_attachement_id_from_url( $url ); $attachment_id = $id_and_size['attachment_id']; $size = $id_and_size['size']; } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'image id and found size' ); do_action( 'optml_log', $attachment_id ); do_action( 'optml_log', $size ); } if ( false === $attachment_id || ! $this->is_legacy_offloaded_attachment( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) { continue; } $optimized_url = wp_get_attachment_image_src( $attachment_id, $size ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' image url to replace with ' ); do_action( 'optml_log', $optimized_url ); } if ( ! isset( $optimized_url[0] ) ) { continue; } if ( $is_original_uploaded === self::is_uploaded_image( $optimized_url[0] ) ) { continue; } $content = str_replace( $url, $optimized_url[0], $content ); } $data['post_content'] = wp_slash( $content ); return $data; } /** * Get all images that need to be updated from a post. * * @param string $post_content The content of the post. * @param string $job The job name. * * @return array An array containing the image ids. */ public function get_image_id_from_content( $post_content, $job ) { $content = trim( wp_unslash( $post_content ) ); $images = Optml_Manager::instance()->extract_urls_from_content( $content ); $found_images = []; if ( isset( $images[0] ) ) { foreach ( $images as $url ) { $is_original_uploaded = self::is_uploaded_image( $url ); $attachment_id = false; if ( $is_original_uploaded ) { if ( $job === 'rollback_images' ) { $attachment_id = self::get_attachment_id_from_url( $url ); } } else { if ( $job === 'offload_images' ) { $id_and_size = $this->get_local_attachement_id_from_url( $url ); $attachment_id = $id_and_size['attachment_id']; } } if ( false === $attachment_id || $attachment_id === 0 || ! wp_attachment_is_image( $attachment_id ) ) { continue; } $found_images[] = intval( $attachment_id ); } } return apply_filters( 'optml_content_images_to_update', $found_images, $content ); } /** * Get the posts ids and the images from them that need sync/rollback. * * @param int $page The current page from the query. * @param string $job The job name rollback_images/offload_images. * @param int $batch How many posts to query on a page. * @param array $page_in The pages that need to be updated. * * @return array An array containing the page of the query and an array containing the images for every post that need to be updated. */ public function update_content( $page, $job, $batch = 1, $page_in = [] ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' updating_content ' ); } $post_types = array_values( array_filter( get_post_types(), function ( $post_type ) { if ( $post_type === 'attachment' || $post_type === 'revision' ) { return false; } return true; } ) ); $query_args = apply_filters( 'optml_replacement_wp_query_args', [ 'post_type' => $post_types, 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => $batch, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, ] ); $query_args = self::add_page_meta_query_args( $job, $query_args ); if ( ! empty( $page_in ) ) { $query_args['post__in'] = $page_in; } $content = new \WP_Query( $query_args ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', $page ); } $images_to_update = []; if ( $content->have_posts() ) { while ( $content->have_posts() ) { $content->the_post(); $content_id = get_the_ID(); if ( get_post_type() !== 'attachment' ) { $ids = $this->get_image_id_from_content( get_post_field( 'post_content', $content_id ), $job ); if ( count( $ids ) > 0 ) { $images_to_update[ $content_id ] = $ids; $duplicated_pages = apply_filters( 'optml_offload_duplicated_images', [], $content_id ); if ( is_array( $duplicated_pages ) && ! empty( $duplicated_pages ) ) { foreach ( $duplicated_pages as $duplicated_id ) { $duplicated_ids = $this->get_image_id_from_content( get_post_field( 'post_content', $duplicated_id ), $job ); $images_to_update[ $duplicated_id ] = $duplicated_ids; } } } if ( $job === 'offload_images' ) { update_post_meta( $content_id, self::POST_OFFLOADED_FLAG, 'true' ); delete_post_meta( $content_id, self::POST_ROLLBACK_FLAG ); } if ( $job === 'rollback_images' ) { update_post_meta( $content_id, self::POST_ROLLBACK_FLAG, 'true' ); delete_post_meta( $content_id, self::POST_OFFLOADED_FLAG ); } } } ++$page; } $result['page'] = $page; $result['imagesToUpdate'] = $images_to_update; return $result; } /** * Add inline action to push to our servers. * * @param array $actions All actions. * @param \WP_Post $post The current post image object. * * @return array */ public function add_inline_media_action( $actions, $post ) { $meta = wp_get_attachment_metadata( $post->ID ); if ( ! isset( $meta['file'] ) ) { return $actions; } $file = $meta['file']; if ( wp_check_filetype( $file, Optml_Config::$all_extensions )['ext'] === false || ! current_user_can( 'delete_post', $post->ID ) ) { return $actions; } if ( ! self::is_uploaded_image( $file ) ) { $upload_action_url = add_query_arg( [ 'page' => 'optimole', 'optimole_action' => 'offload_images', '0' => $post->ID, ], 'admin.php' ); $actions['offload_images'] = sprintf( '%s', $upload_action_url, esc_attr__( 'Offload to Optimole', 'optimole-wp' ), esc_html__( 'Offload to Optimole', 'optimole-wp' ) ); } if ( self::is_uploaded_image( $file ) ) { $rollback_action_url = add_query_arg( [ 'page' => 'optimole', 'optimole_action' => 'rollback_images', '0' => $post->ID, ], 'admin.php' ); $actions['rollback_images'] = sprintf( '%s', $rollback_action_url, esc_attr__( 'Restore image to media library', 'optimole-wp' ), esc_html__( 'Restore image to media library', 'optimole-wp' ) ); } return $actions; } /** * Upload images to our servers and update inside pages. * * @param array $image_ids The id of the attachments for the selected images. * * @return int The number of successfully processed images. */ public function upload_and_update_existing_images( $image_ids ) { $success_up = 0; if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' images to upload ' ); do_action( 'optml_log', $image_ids ); } foreach ( $image_ids as $id ) { if ( self::is_uploaded_image( wp_get_attachment_metadata( $id )['file'] ) ) { // if this meta flag below failed at the initial update but the file meta above is updated it will cause an infinite query loop update_post_meta( $id, self::META_KEYS['offloaded'], 'true' ); update_post_meta( $id, self::OM_OFFLOADED_FLAG, true ); ++$success_up; continue; } $meta = $this->generate_image_meta( wp_get_attachment_metadata( $id ), $id ); if ( isset( $meta['file'] ) && self::is_uploaded_image( $meta['file'] ) ) { ++$success_up; wp_update_attachment_metadata( $id, $meta ); } } if ( $success_up > 0 ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' call post update, succesful images: ' ); do_action( 'optml_log', $success_up ); } } return $success_up; } /** * Return the original url of an image attachment. * * @param integer $post_id Image attachment id. * * @return string|bool The original url of the image. */ public static function get_original_url( $post_id ) { self::$return_original_url = true; $original_url = wp_get_attachment_url( $post_id ); self::$return_original_url = false; return $original_url; } /** * Bring images back to media library and update inside pages. * * @param array $image_ids The id of the attachments for the selected images. * * @return int The number of successfully processed images. */ public function rollback_and_update_images( $image_ids ) { $success_back = 0; if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' images to rollback ' ); do_action( 'optml_log', $image_ids ); } foreach ( $image_ids as $id ) { // Skip DAM attachment filtering. if ( $this->is_dam_imported_image( $id ) ) { continue; } $current_meta = wp_get_attachment_metadata( $id ); if ( ! isset( $current_meta['file'] ) || ! self::is_uploaded_image( $current_meta['file'] ) ) { delete_post_meta( $id, self::META_KEYS['offloaded'] ); delete_post_meta( $id, self::OM_OFFLOADED_FLAG ); ++$success_back; continue; } // Account for scaled images. $source_file = isset( $current_meta['original_image'] ) ? $current_meta['original_image'] : $current_meta['file']; // @phpstan-ignore-line - this exists for scaled images. $filename = pathinfo( $source_file, PATHINFO_BASENAME ); $image_id = preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $current_meta['file'], $matches ) ? $matches[1] : null; if ( null === $image_id ) { continue; } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' image cloud id ' ); do_action( 'optml_log', $image_id ); } $image_url = Optimole::offload()->getImageUrl( $image_id ); if ( null === $image_url ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' error get url' ); } self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error getting URL.' ); continue; } if ( ! function_exists( 'download_url' ) ) { include_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! function_exists( 'download_url' ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } $timeout_seconds = 60; $temp_file = download_url( $image_url, $timeout_seconds ); if ( is_wp_error( $temp_file ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' download_url error ' ); } self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error downloading URL.' ); continue; } $extension = $this->get_ext( $filename ); if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' image has invalid extension' ); do_action( 'optml_log', $extension ); } update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has invalid extension.' ); continue; } $type = Optml_Config::$image_extensions [ $extension ]; $file = [ 'name' => $filename, 'type' => $type, 'tmp_name' => $temp_file, 'error' => 0, 'size' => filesize( $temp_file ), ]; $overrides = [ // do not expect the default form data from normal uploads 'test_form' => false, // Setting this to false lets WordPress allow empty files, not recommended. 'test_size' => true, // A properly uploaded file will pass this test. There should be no reason to override this one. 'test_upload' => true, ]; if ( ! function_exists( 'wp_handle_sideload' ) ) { include_once ABSPATH . '/wp-admin/includes/file.php'; } if ( ! function_exists( 'wp_handle_sideload' ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } // Move the temporary file into the uploads directory. $results = wp_handle_sideload( $file, $overrides, get_the_date( 'Y/m', $id ) ); if ( ! empty( $results['error'] ) ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' wp_handle_sideload error' ); } update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' faced wp_handle_sideload error.' ); continue; } if ( ! function_exists( 'wp_create_image_subsizes' ) ) { include_once ABSPATH . '/wp-admin/includes/image.php'; } if ( ! function_exists( 'wp_create_image_subsizes' ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } $original_meta = wp_create_image_subsizes( $results['file'], $id ); if ( $type === 'image/svg+xml' ) { if ( ! function_exists( 'wp_get_attachment_metadata' ) || ! function_exists( 'wp_update_attachment_metadata' ) ) { include_once ABSPATH . '/wp-admin/includes/post.php'; } if ( ! function_exists( 'wp_get_attachment_metadata' ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } $meta = wp_get_attachment_metadata( $id ); if ( ! isset( $meta['file'] ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } $meta['file'] = $results['file']; wp_update_attachment_metadata( $id, $meta ); } if ( ! function_exists( 'update_attached_file' ) ) { include_once ABSPATH . '/wp-admin/includes/post.php'; } if ( ! function_exists( 'update_attached_file' ) ) { update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' ); continue; } update_attached_file( $id, $results['file'] ); $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $id ); if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) { foreach ( $duplicated_images as $duplicated_id ) { $duplicated_meta = wp_get_attachment_metadata( $duplicated_id ); if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) { $duplicated_meta['file'] = $results['file']; if ( isset( $meta ) ) { foreach ( $meta['sizes'] as $key => $value ) { if ( isset( $original_meta['sizes'][ $key ]['file'] ) ) { $duplicated_meta['sizes'][ $key ]['file'] = $original_meta['sizes'][ $key ]['file']; } } } wp_update_attachment_metadata( $duplicated_id, $duplicated_meta ); delete_post_meta( $duplicated_id, self::META_KEYS['offloaded'] ); delete_post_meta( $duplicated_id, self::OM_OFFLOADED_FLAG ); } } } ++$success_back; self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has been rolled back.' ); $original_url = self::get_original_url( $id ); if ( $original_url === false ) { continue; } $this->delete_attachment_from_server( $original_url, $id, $image_id ); } if ( $success_back > 0 ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', ' call update post, success rollback' ); do_action( 'optml_log', $success_back ); } } return $success_back; } /** * Handle the bulk actions. * * @param string $redirect The current url from the media library. * @param string $doaction The current action selected. * @param array $image_ids The id of the attachments for the selected images. * * @return string The url with the correspondent query args for the executed actions. */ public function bulk_action_handler( $redirect, $doaction, $image_ids ) { if ( empty( $image_ids ) ) { return $redirect; } $image_ids = array_slice( $image_ids, 0, 20, true ); $redirect = 'admin.php'; $redirect = add_query_arg( 'optimole_action', $doaction, $redirect ); $redirect = add_query_arg( 'page', 'optimole', $redirect ); $redirect = add_query_arg( $image_ids, $redirect ); return $redirect; } /** * Register the bulk media actions. * * @param array $bulk_array The existing actions array. * * @return array The array with the appended actions. */ public function register_bulk_media_actions( $bulk_array ) { $bulk_array['offload_images'] = __( 'Push Image to Optimole', 'optimole-wp' ); $bulk_array['rollback_images'] = __( 'Restore image to media library', 'optimole-wp' ); return $bulk_array; } /** * Send delete request to our servers and update the meta. * * @param string $original_url Original url of the image. * @param integer $post_id Image id inside db. * @param string $image_id Our cloud id for the image. */ public function delete_attachment_from_server( $original_url, $post_id, $image_id ) { Optimole::offload()->deleteImage( $image_id ); delete_post_meta( $post_id, self::META_KEYS['offloaded'] ); delete_post_meta( $post_id, self::OM_OFFLOADED_FLAG ); } /** * Delete an image from our servers after it is removed from media. * * @param int $post_id The deleted post id. */ public function delete_attachment_hook( $post_id ) { $file = wp_get_attachment_metadata( $post_id ); if ( $file === false ) { return; } // Skip if the image was imported from cloud library. if ( $this->is_dam_imported_image( $post_id ) ) { return; } if ( ! $this->is_new_offloaded_attachment( $post_id ) && ! $this->is_legacy_offloaded_attachment( $post_id ) ) { return; } $file = $file['file']; if ( self::is_uploaded_image( $file ) || $this->is_new_offloaded_attachment( $post_id ) ) { $original_url = self::get_original_url( $post_id ); if ( $original_url === false ) { return; } $table_id = []; preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $file, $table_id ); if ( ! isset( $table_id[1] ) ) { return; } $this->delete_attachment_from_server( $original_url, $post_id, $table_id[1] ); } } /** * Get optimized URL for an attachment image if it is uploaded to our servers. * * @param string $url The current url. * @param int $attachment_id The attachment image id. * * @return string Optimole cdn URL. * @uses filter:wp_get_attachment_url */ public function get_image_attachment_url( $url, $attachment_id ) { if ( self::$return_original_url === true ) { return $url; } if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) { $meta = wp_get_attachment_metadata( $attachment_id ); if ( ! isset( $meta['file'] ) ) { return $url; } // Skip DAM attachment filtering. if ( $this->is_dam_imported_image( $attachment_id ) ) { return $url; } $file = $meta['file']; if ( self::is_uploaded_image( $file ) ) { return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $attachment_id . $file, $this->get_optimized_image_url( $url, 'auto', 'auto' ) ); } else { // this is for the users that already offloaded the images before the other fixes $local_file = get_attached_file( $attachment_id ); if ( ! file_exists( $local_file ) ) { $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id ); if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) { foreach ( $duplicated_images as $id ) { if ( ! empty( $id ) ) { $duplicated_meta = wp_get_attachment_metadata( $id ); if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) { return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $id . $duplicated_meta['file'], $this->get_optimized_image_url( $url, 'auto', 'auto' ) ); } } } } } } return $url; } if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { return $url; } return $this->get_new_offloaded_attachment_url( $url, $attachment_id ); } /** * Filter the requested image url. * * @param bool|array $image The previous image value (null). * @param int $attachment_id The ID of the attachment. * @param string|array $size Requested size of image. Image size name, or array of width and height values (in that order). * * @return bool|array The image sizes and optimized url. * @uses filter:image_downsize */ public function generate_filter_downsize_urls( $image, $attachment_id, $size ) { if ( $this->is_dam_imported_image( $attachment_id ) ) { return $image; } if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) { if ( self::$return_original_url === true ) { return $image; } $sizes2crop = self::size_to_crop(); if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) { return $image; } $data = image_get_intermediate_size( $attachment_id, $size ); if ( false === $data || ! self::is_uploaded_image( $data['url'] ) ) { return $image; } $resize = apply_filters( 'optml_default_crop', [] ); if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) { $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] ); } $id_filename = []; preg_match( '/\/(' . self::KEYS['not_processed_flag'] . '.*)/', $data['url'], $id_filename ); if ( ! isset( $id_filename[1] ) ) { return $image; } $url = self::get_original_url( $attachment_id ); return [ str_replace( $url, $id_filename[1], $this->get_optimized_image_url( $url, $data['width'], $data['height'], $resize ) ), $data['width'], $data['height'], true, ]; } if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { return $image; } return $this->alter_attachment_image_src( $image, $attachment_id, $size, false ); } /** * Get image extension. * * @param string $path Image path. * * @return string */ private function get_ext( $path ) { return pathinfo( $path, PATHINFO_EXTENSION ); } /** * Mark an image as having a retryable error. * * @param int $attachment_id The attachment ID. * @param string $reason The reason for the error. */ public static function mark_retryable_error( $attachment_id, $reason ) { static $allowed_retries = 5; $retries = get_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, true ); $retries = empty( $retries ) ? 0 : (int) $retries; if ( $retries >= $allowed_retries ) { self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Reached the maximum number of retries.' ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); return; } self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Marked for retry, retries done: ' . $retries ); update_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, ( $retries + 1 ) ); } /** * Update image meta with optimized cdn path. * * @param array $meta Meta information of the image. * @param int $attachment_id The image attachment ID. * * @return array * @uses filter:wp_generate_attachment_metadata */ public function generate_image_meta( $meta, $attachment_id ) { if ( $this->is_dam_imported_image( $attachment_id ) ) { return $meta; } if ( self::$instance->settings->is_offload_limit_reached() ) { return $meta; } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'called generate meta' ); } // No meta, or image was already uploaded. if ( ! isset( $meta['file'] ) || ! isset( $meta['width'] ) || ! isset( $meta['height'] ) || self::is_uploaded_image( $meta['file'] ) ) { do_action( 'optml_log', 'invalid meta' ); do_action( 'optml_log', $meta ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid meta.' ); return $meta; } // Skip images based on filters. if ( false === Optml_Filters::should_do_image( $meta['file'], self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_FILENAME ] ) ) { do_action( 'optml_log', 'optimization filter' ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); return $meta; } $original_url = self::get_original_url( $attachment_id ); // Could not find original URL. if ( $original_url === false ) { do_action( 'optml_log', 'error getting original url' ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid original url.' ); return $meta; } // We should strip the `-scaled` from the URL to not generate inconsistencies with automatically scaled images. $original_url = $this->maybe_strip_scaled( $original_url ); $local_file = $this->maybe_strip_scaled( get_attached_file( $attachment_id ) ); $extension = $this->get_ext( $local_file ); $content_type = Optml_Config::$image_extensions [ $extension ]; $temp = explode( '/', $local_file ); $file_name = end( $temp ); $no_ext_filename = str_replace( '.' . $extension, '', $file_name ); $original_name = $file_name; if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'file before replace' ); do_action( 'optml_log', $local_file ); } // check if the current filename is the last deduplicated filename if ( ! empty( self::$last_deduplicated ) && strpos( $no_ext_filename, str_replace( '.' . $extension, '', self::$last_deduplicated ) ) !== false ) { // replace the file with the original before deduplication to get the path where the image is uploaded $local_file = str_replace( $file_name, self::$last_deduplicated, $local_file ); $original_name = self::$last_deduplicated; self::$last_deduplicated = false; } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'file after replace' ); do_action( 'optml_log', $local_file ); } if ( ! file_exists( $local_file ) ) { update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); do_action( 'optml_log', 'missing file' ); do_action( 'optml_log', $local_file ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has missing file.' ); return $meta; } if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) { update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); do_action( 'optml_log', 'invalid extension' ); do_action( 'optml_log', $extension ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid extension.' ); return $meta; } if ( false === Optml_Filters::should_do_extension( self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_EXT ], $extension ) ) { do_action( 'optml_log', 'extension filter' ); do_action( 'optml_log', $extension ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); return $meta; } $offload_manager = Optimole::offload(); $offload_usage = $offload_manager->getUsage(); $current_run = self::get_process_meta(); $remaining = isset( $current_run['remaining'] ) ? absint( $current_run['remaining'] ) : 0; if ( $remaining + $offload_usage->getCurrent() >= $offload_usage->getLimit() ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'limit exceeded' ); do_action( 'optml_log', $offload_usage ); } self::$instance->settings->update( 'offload_limit_reached', 'enabled' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: offloading images would exceed limit.' ); return $meta; } try { $image_id = $offload_manager->uploadImage( $local_file, $original_url ); if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'image id' ); do_action( 'optml_log', $image_id ); } // We clear the retry counter if we reach this point. delete_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER ); } catch ( InvalidArgumentException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'invalid argument exception' ); do_action( 'optml_log', $exception ); } update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' file is missing or unreadable.' ); return $meta; } catch ( InvalidUploadApiResponseException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'missing table id or upload url' ); do_action( 'optml_log', $exception ); } update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid table id or upload url.' ); return $meta; } catch ( UploadFailedException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'upload error' ); do_action( 'optml_log', $exception ); } update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has upload error.' ); return $meta; } catch ( UploadLimitException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'limit exceeded' ); do_action( 'optml_log', $exception ); } self::$instance->settings->update( 'offload_limit', $exception->getUsage()->getLimit() ); self::$instance->settings->update( 'offload_limit_reached', 'enabled' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: upload limit exceeded' ); return $meta; } catch ( UploadApiException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'upload api error' ); do_action( 'optml_log', $exception ); } self::mark_retryable_error( $attachment_id, 'Image ID: ' . $attachment_id . ' has an error from upload api:' . $exception->getMessage() ); return $meta; } catch ( RuntimeException $exception ) { if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'runtime exception' ); do_action( 'optml_log', $exception ); } update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has an issue.' ); return $meta; } $url_to_append = $original_url; $url_parts = parse_url( $original_url ); if ( isset( $url_parts['scheme'] ) && isset( $url_parts['host'] ) ) { $url_to_append = $url_parts['scheme'] . '://' . $url_parts['host'] . '/' . $file_name; } $optimized_url = $this->get_media_optimized_url( $url_to_append, $image_id ); if ( ( new Optml_Api() )->check_optimized_url( $optimized_url ) === false ) { do_action( 'optml_log', 'optimization error' ); do_action( 'optml_log', $optimized_url ); Optimole::offload()->deleteImage( $image_id ); update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has optimization error.' ); return $meta; } @unlink( $local_file ); update_post_meta( $attachment_id, self::META_KEYS['offloaded'], 'true' ); update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ); $meta['file'] = '/' . self::KEYS['uploaded_flag'] . $image_id . '/' . $url_to_append; if ( isset( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $key => $value ) { $generated_image_size_path = str_replace( $original_name, $meta['sizes'][ $key ]['file'], $local_file ); file_exists( $generated_image_size_path ) && unlink( $generated_image_size_path ); $meta['sizes'][ $key ]['file'] = $file_name; } } // This is needed for scaled images. // Otherwise, `-scaled` images will be left behind. if ( isset( $meta['original_image'] ) ) { $ext = $this->get_ext( $local_file ); $scaled_path = str_replace( '.' . $ext, '-scaled.' . $ext, $local_file ); file_exists( $scaled_path ) && unlink( $scaled_path ); } $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id ); if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) { foreach ( $duplicated_images as $duplicated_id ) { $duplicated_meta = wp_get_attachment_metadata( $duplicated_id ); if ( isset( $duplicated_meta['file'] ) && ! self::is_uploaded_image( $duplicated_meta['file'] ) ) { $duplicated_meta['file'] = $meta['file']; if ( $duplicated_meta['sizes'] ) { foreach ( $meta['sizes'] as $key => $value ) { $duplicated_meta['sizes'][ $key ]['file'] = $file_name; } } wp_update_attachment_metadata( $duplicated_id, $duplicated_meta ); update_post_meta( $duplicated_id, self::META_KEYS['offloaded'], 'true' ); update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ); } } } if ( OPTML_DEBUG_MEDIA ) { do_action( 'optml_log', 'success offload' ); } self::decrement_process_meta_remaining(); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has been offloaded.' ); $attachment_page_id = wp_get_post_parent_id( $attachment_id ); if ( $attachment_page_id !== false && $attachment_page_id !== 0 ) { self::$offload_update_post = true; update_post_meta( $attachment_page_id, self::POST_OFFLOADED_FLAG, 'true' ); self::$offload_update_post = false; } return $meta; } /** * Get the args for wp query according to the scope. * * @param int $batch Number of images to get. * @param string $action The action for which to get the images. * * @return array|false The query options array or false if not passed a valid action. */ public static function get_images_or_pages_query_args( $batch, $action, $get_images = false ) { $args = [ 'posts_per_page' => $batch, 'fields' => 'ids', 'ignore_sticky_posts' => false, 'no_found_rows' => true, ]; if ( $get_images === true ) { $args['post_type'] = 'attachment'; $args['post_mime_type'] = 'image'; $args['post_status'] = 'inherit'; // Offload args. if ( $action === 'offload_images' ) { $args['meta_query'] = [ 'relation' => 'AND', [ 'key' => self::META_KEYS['offloaded'], 'compare' => 'NOT EXISTS', ], [ 'key' => self::META_KEYS['offload_error'], 'compare' => 'NOT EXISTS', ], ]; return $args; } // Rollback args. $args['meta_query'] = [ 'relation' => 'AND', [ 'key' => self::META_KEYS['offloaded'], 'value' => 'true', 'compare' => '=', ], [ 'key' => self::META_KEYS['rollback_error'], 'compare' => 'NOT EXISTS', ], [ 'key' => Optml_Dam::OM_DAM_IMPORTED_FLAG, 'compare' => 'NOT EXISTS', ], ]; return $args; } $args = self::add_page_meta_query_args( $action, $args ); $post_types = array_filter( get_post_types(), function ( $post_type ) { if ( $post_type === 'attachment' || $post_type === 'revision' ) { return false; } return true; } ); $args ['post_type'] = array_values( $post_types ); return $args; } /** * Query the database and upload images to our servers. * * @param int $batch Number of images to process in a batch. * * @return array Number of found images and number of successfully processed images. */ public function upload_images( $batch, $images = [] ) { self::$instance->settings->update( 'offload_limit_reached', 'disabled' ); if ( empty( $images ) || $images === 'none' ) { $args = self::get_images_or_pages_query_args( $batch, 'offload_images', true ); $attachments = new \WP_Query( $args ); $ids = $attachments->get_posts(); } else { $ids = array_slice( $images, 0, $batch ); } $result = [ 'found_images' => count( $ids ) ]; $result['success_offload'] = $this->upload_and_update_existing_images( $ids ); return $result; } /** * Query the database and bring back image to media library. * * @param int $batch Number of images to process in a batch. * * @return array Number of found images and number of successfully processed images. */ public function rollback_images( $batch, $images = [] ) { if ( empty( $images ) || $images === 'none' ) { $args = self::get_images_or_pages_query_args( $batch, 'rollback_images', true ); $attachments = new \WP_Query( $args ); $ids = $attachments->get_posts(); } else { $ids = array_slice( $images, 0, $batch ); } $result = [ 'found_images' => count( $ids ) ]; $result['success_rollback'] = $this->rollback_and_update_images( $ids ); return $result; } /** * Update the post with the given id, the images will be updated by the filters we use. * * @param int $post_id The post id to update. * * @return bool Whether the update was succesful or not. */ public function update_page( $post_id ) { self::$offload_update_post = true; $post_update = wp_update_post( [ 'ID' => $post_id ] ); self::$offload_update_post = false; if ( $post_update === 0 ) { return false; } do_action( 'optml_updated_post', $post_id ); return true; } /** * Calculate the number of images in media library and the number of posts/pages. * * @param string $action The actions for which to get the number of images. * * @return int Number of images. */ public static function number_of_images_and_pages( $action ) { $images_args = self::get_images_or_pages_query_args( - 1, $action, true ); $images = new \WP_Query( $images_args ); // With the new mechanism, when offloading images, we don't need to address pages anymore. // Bail early with the number of images. if ( $action === 'offload_images' ) { return $images->post_count; } $pages_args = self::get_images_or_pages_query_args( - 1, $action ); $pages = new \WP_Query( $pages_args ); return $pages->post_count + $images->post_count; } /** * Calculate the number of images in media library and the number of posts/pages by IDs. * * @param string $action The actions for which to get the number of images. * * @return int Number of images. */ public static function number_of_images_by_ids( $action, $ids ) { $args = self::get_images_or_pages_query_args( - 1, $action, true ); $args['post__in'] = $ids; $images = new \WP_Query( $args ); return $images->post_count; } /** * Get pages that contain images by IDs. * * @param string $action The actions for which to get the number of images. * @param array $images Image IDs. * @param int $batch Batch count. * @param int $page Page number. */ public static function get_posts_by_image_ids( $action, $images = [], $batch = 10, $page = 1 ) { if ( empty( $images ) ) { return []; } $transient_key = 'optml_images_' . md5( serialize( $images ) ); $transient = get_transient( $transient_key ); if ( false !== $transient ) { return array_slice( $transient, ( $page - 1 ) * $batch, $batch ); } global $wpdb; $image_urls = array_map( function ( $image_id ) { $meta = wp_get_attachment_metadata( $image_id ); $extension = Optml_Media_Offload::instance()->get_ext( $meta['file'] ); return str_replace( '.' . $extension, '', $meta['file'] ); }, $images ); // Sanitize the image URLs for use in the SQL query. $urls = array_map( 'esc_url_raw', $image_urls ); // Initialize an empty string to hold the query. $query = ''; // Iterate through the array and add each URL to the query. foreach ( $urls as $index => $url ) { // If it's the first item, we don't need to add OR to the beginning. if ( $index === 0 ) { $query .= $wpdb->prepare( 'post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' ); } else { $query .= $wpdb->prepare( ' OR post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' ); } } // Get all the posts IDs by using LIMIT and offset in a loop. $ids = []; $offset = 0; $limit = $batch; while ( true ) { $posts = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE $query LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $limit, $offset ) ); if ( empty( $posts ) ) { break; } $ids = array_merge( $ids, $posts ); $offset += $limit; } set_transient( $transient_key, $ids, HOUR_IN_SECONDS ); return array_slice( $ids, ( $page - 1 ) * $batch, $batch ); } /** * Record process meta, * * @param int $count The number of images to process. * * @return void */ public static function record_process_meta( $count ) { $meta = get_option( 'optml_process_meta', [] ); $meta['count'] = $count; $meta['remaining'] = $count; $meta['start_time'] = time(); update_option( 'optml_process_meta', $meta ); } /** * Update the process meta count. * * @return void */ public static function decrement_process_meta_remaining() { $meta = get_option( 'optml_process_meta', [] ); if ( ! isset( $meta['remaining'] ) ) { return; } $meta['remaining'] = $meta['remaining'] - 1; update_option( 'optml_process_meta', $meta ); } /** * Get process meta, * * @return array */ public static function get_process_meta() { $res = []; $meta = get_option( 'optml_process_meta', [] ); $res['time_passed'] = isset( $meta['start_time'] ) ? ( time() - $meta['start_time'] ) / 60 : 0; $res['count'] = isset( $meta['count'] ) ? $meta['count'] : 0; $res['remaining'] = isset( $meta['remaining'] ) ? $meta['remaining'] : $res['count']; return $res; } /** * Calculate the number of images in media library and the number of posts/pages. * * @param string $action The actions for which to get the number of images. * @param bool $refresh Whether to refresh the cron or not. * @param array $images The images to process. * * @return array Image count and Cron status. */ public static function get_image_count( $action, $refresh, $images = [] ) { $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status'; $count = 0; $step = 0; $batch = apply_filters( 'optimole_offload_batch', 20 ); // Reduce this to smaller if we have memory issues during testing. if ( empty( $images ) ) { $count = Optml_Media_Offload::number_of_images_and_pages( $action ); } else { $count = Optml_Media_Offload::number_of_images_by_ids( $action, $images ); } $possible_batch = ceil( $count / 10 ); if ( $possible_batch < $batch ) { $batch = $possible_batch; } // If batch is less than 10, set it to 10. if ( $batch < 10 ) { $batch = 10; } $in_progress = self::$instance->settings->get( $option ) !== 'disabled'; if ( $count === 0 ) { $in_progress = false; } $type = 'offload_images' === $action ? 'offload' : 'rollback'; // We save the status if this ia multi step transfer. if ( empty( $images ) ) { self::$instance->settings->update( 'transfer_status', $action ); } if ( false === $refresh ) { // We check also the alternative action to avoid doing both in the same time and disable the running one. $in_progress_b = self::$instance->settings->get( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status' ) !== 'disabled'; // We do this only if there is a mass action in progress, not individual ones. if ( $in_progress_b && empty( $images ) ) { // We stop the oposite action from going any further. self::$instance->settings->update( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status', 'disabled' ); } self::$instance->settings->update( 'offload_limit_reached', 'disabled' ); self::record_process_meta( $count ); self::$instance->settings->update( $option, $in_progress ? 'enabled' : 'disabled' ); self::$instance->logger->add_log( $type, Optml_Logger::LOG_SEPARATOR ); self::$instance->logger->add_log( $type, 'Started with a total count of ' . intval( $count ) . '.' ); if ( $in_progress !== true ) { return [ 'count' => $count, 'status' => $in_progress, 'action' => $type, ]; } if ( empty( $images ) ) { $total = ceil( $count / $batch ); self::schedule_action( time(), 'optml_start_processing_images', [ $action, $batch, 1, $total, $step, ] ); } else { self::schedule_action( time(), 'optml_start_processing_images_by_id', [ $action, $batch, 1, $images, ] ); } } $response = [ 'count' => $count, 'action' => $type, ]; if ( $type === 'offload' ) { $offload_limit_reached = self::$instance->settings->is_offload_limit_reached(); if ( $offload_limit_reached ) { $in_progress = false; self::$instance->settings->update( $option, 'disabled' ); } $response['reached_limit'] = self::$instance->settings->is_offload_limit_reached(); $response['offload_limit'] = self::$instance->settings->get( 'offload_limit' ); } $response['status'] = $in_progress; return $response; } /** * Schedule an action. * * @param int $time The time to schedule the action. * @param string $hook The hook to schedule. * @param array $args The arguments to pass to the hook. * * @return mixed */ public static function schedule_action( $time, $hook, $args ) { // We use AS if available to avoid issues with WP Cron. if ( function_exists( 'as_schedule_single_action' ) ) { return as_schedule_single_action( $time, $hook, $args ); } else { return wp_schedule_single_event( $time, $hook, $args ); } } /** * Check if an action hook is scheduled. * * @param string $hook The hook to check. * * @return bool */ public static function is_scheduled( $hook ) { if ( function_exists( 'as_has_scheduled_action' ) ) { return as_has_scheduled_action( $hook ); } elseif ( function_exists( 'as_next_scheduled_action' ) ) { // For older versions of AS. return as_next_scheduled_action( $hook ) !== false; } else { return wp_next_scheduled( $hook ) !== false; } } /** * Start Processing Images by IDs * * @param string $action The action for which to get the number of images. * @param int $batch The batch of images to process. * @param int $page The page of images to process. * @param array $image_ids The images to process. * * @return void */ public function start_processing_images_by_id( $action, $batch, $page, $image_ids = [] ) { $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status'; $type = 'offload_images' === $action ? Optml_Logger::LOG_TYPE_OFFLOAD : Optml_Logger::LOG_TYPE_ROLLBACK; if ( self::$instance->settings->get( $option ) === 'disabled' ) { return; } set_time_limit( 0 ); // Only use the legacy offloaded attachments to query the pages that need to be updated. // We can be confident that these IDs are already marked as offloaded. $legacy_offloaded = array_filter( $image_ids, function ( $id ) { return ! $this->is_new_offloaded_attachment( $id ); } ); // On the new mechanism, we don't update posts anymore when offloading. $page_in = $action === 'offload_images' ? [] : Optml_Media_Offload::get_posts_by_image_ids( $action, $legacy_offloaded, $batch, $page ); if ( empty( $image_ids ) && empty( $page_in ) && empty( $legacy_offloaded ) ) { $meta = self::get_process_meta(); self::$instance->logger->add_log( $type, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' ); self::$instance->settings->update( $option, 'disabled' ); return; } try { // This will be 0 in the case of offloading now. if ( $action === 'rollback_images' && 0 !== count( $page_in ) ) { $to_update = Optml_Media_Offload::instance()->update_content( $page, $action, $batch, $page_in ); if ( isset( $to_update['page'] ) ) { if ( isset( $to_update['imagesToUpdate'] ) && count( $to_update['imagesToUpdate'] ) ) { foreach ( $to_update['imagesToUpdate'] as $post_id => $images ) { if ( ! empty( $image_ids ) ) { $images = array_intersect( $images, $image_ids ); } if ( empty( $images ) ) { continue; } Optml_Media_Offload::instance()->rollback_and_update_images( $images ); Optml_Media_Offload::instance()->update_page( $post_id ); } } } $page = $page + 1; } else { // From $image_ids get the number as per $batch and save it in $page_in and update $images with the remaining images. $images = array_slice( $image_ids, 0, $batch ); $image_ids = array_slice( $image_ids, $batch ); $action === 'rollback_images' ? Optml_Media_Offload::instance()->rollback_images( $batch, $images ) : Optml_Media_Offload::instance()->upload_images( $batch, $images ); } self::schedule_action( time(), 'optml_start_processing_images_by_id', [ $action, $batch, $page, $image_ids, ] ); } catch ( Exception $e ) { // Reschedule the cron to run again after a delay. Sometimes memory limit is exhausted. $delay_in_seconds = 10; self::$instance->logger->add_log( $type, $e->getMessage() ); self::schedule_action( time() + $delay_in_seconds, 'optml_start_processing_images_by_id', [ $action, $batch, $page, $image_ids, ] ); } } /** * Start Processing Images * * @param string $action The action for which to get the number of images. * @param int $batch The batch of images to process. * @param int $page The page of images to process. * @param int $total The total number of pages. * @param int $step The current step. * * @return void */ public function start_processing_images( $action, $batch, $page, $total, $step ) { $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status'; $type = 'offload_images' === $action ? 'offload' : 'rollback'; if ( self::$instance->settings->get( $option ) === 'disabled' ) { return; } if ( $step > $total || 0 === $total ) { $meta = self::get_process_meta(); self::$instance->logger->add_log( $type, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' ); self::$instance->settings->update( $option, 'disabled' ); self::$instance->settings->update( 'show_offload_finish_notice', $type ); return; } set_time_limit( 0 ); try { $posts_to_update = $action === 'offload_images' ? [] : Optml_Media_Offload::instance()->update_content( $page, $action, $batch ); // Kept for backward compatibility with old offloading mechanism where pages were modified. if ( $action === 'rollback_images' && isset( $posts_to_update['page'] ) && $posts_to_update['page'] > $page ) { $page = $posts_to_update['page']; if ( isset( $posts_to_update['imagesToUpdate'] ) && count( $posts_to_update['imagesToUpdate'] ) ) { foreach ( $posts_to_update['imagesToUpdate'] as $post_id => $images ) { Optml_Media_Offload::instance()->rollback_and_update_images( $images ); Optml_Media_Offload::instance()->update_page( $post_id ); } } } else { $action === 'rollback_images' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch ); } $step = $step + 1; self::schedule_action( time(), 'optml_start_processing_images', [ $action, $batch, $page, $total, $step, ] ); } catch ( Exception $e ) { // Reschedule the cron to run again after a delay. Sometimes memory limit is exausted. $delay_in_seconds = 10; self::$instance->logger->add_log( $type, $e->getMessage() ); self::schedule_action( time() + $delay_in_seconds, 'optml_start_processing_images', [ $action, $batch, $page, $total, $step, ] ); } } /** * Alter attachment image src for offloaded images. * * @param array|false $image { * Array of image data. * * @type string $0 Image source URL. * @type int $1 Image width in pixels. * @type int $2 Image height in pixels. * @type bool $3 Whether the image is a resized image. * } * * @param int $attachment_id attachment id. * @param string|int[] $size image size. * @param bool $icon Whether the image should be treated as an icon. * * @return array $image. */ public function alter_attachment_image_src( $image, $attachment_id, $size, $icon ) { if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { return $image; } $url = get_post( $attachment_id ); $url = $url->guid; $image_url = $this->get_new_offloaded_attachment_url( $url, $attachment_id ); $metadata = wp_get_attachment_metadata( $attachment_id ); // Use the original size if the requested size is full. if ( $size === 'full' || $this->is_attachment_edit_page( $attachment_id ) ) { $image_url = $this->get_new_offloaded_attachment_url( $url, $attachment_id, [ 'width' => $metadata['width'], 'height' => $metadata['height'], 'attachment_id' => $attachment_id, ] ); return [ $image_url, $metadata['width'], $metadata['height'], false, ]; } $crop = false; // Size can be int [] containing width and height. if ( is_array( $size ) ) { $width = $size[0]; $height = $size[1]; $crop = true; } else { $sizes = $this->get_all_image_sizes(); if ( ! isset( $sizes[ $size ] ) ) { return [ $image_url, $metadata['width'], $metadata['height'], false, ]; } $width = $sizes[ $size ]['width']; $height = $sizes[ $size ]['height']; $crop = is_array( $sizes[ $size ]['crop'] ) ? $sizes[ $size ]['crop'] : (bool) $sizes[ $size ]['crop']; } $sizes2crop = self::size_to_crop(); if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) { return $image; } $resize = apply_filters( 'optml_default_crop', [] ); $data = image_get_intermediate_size( $attachment_id, $size ); if ( is_array( $data ) && isset( $data['width'] ) && isset( $data['height'] ) ) { // @phpstan-ignore-line - these both exist. if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) { $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] ); } } if ( $crop !== false ) { $resize = $this->to_optml_crop( $crop ); } $image_url = $this->get_new_offloaded_attachment_url( $url, $attachment_id, [ 'width' => $width, 'height' => $height, 'resize' => $resize, 'attachment_id' => $attachment_id, ] ); return [ $image_url, $width, $height, $crop, ]; } /** * Needed for image sizes inside the editor. * * @param array $response Array of prepared attachment data. @see wp_prepare_attachment_for_js(). * @param WP_Post $attachment Attachment object. * @param array|false $meta Array of attachment meta data, or false if there is none. * * @return array */ public function alter_attachment_for_js( $response, $attachment, $meta ) { if ( ! $this->is_new_offloaded_attachment( $attachment->ID ) ) { return $response; } $sizes = $this->get_all_image_sizes(); foreach ( $sizes as $size => $args ) { if ( isset( $response['sizes'][ $size ] ) ) { continue; } $args = [ 'height' => $args['height'], 'width' => $args['width'], 'crop' => true, ]; $response['sizes'][ $size ] = array_merge( $args, [ 'url' => $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $args ), 'orientation' => ( $args['height'] > $args['width'] ) ? 'portrait' : 'landscape', ] ); } $url_args = [ 'height' => $response['height'], 'width' => $response['width'], 'crop' => false, ]; $response['url'] = $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $url_args ); return $response; } /** * Alter attachment metadata. * * @param array $metadata The attachment metadata. * @param int $id The attachment ID. * * @return array */ public function alter_attachment_metadata( $metadata, $id ) { if ( ! $this->is_new_offloaded_attachment( $id ) ) { return $metadata; } return $this->get_altered_metadata_for_remote_images( $metadata, $id ); } /** * Get offloaded image attachment URL for new offloads. * * @param string $url The initial attachment URL. * @param int $attachment_id The attachment ID. * @param array $args The additional arguments. * - width: The width of the image. * - height: The height of the image. * - crop: Whether to crop the image. * * @return string */ private function get_new_offloaded_attachment_url( $url, $attachment_id, $args = [] ) { $process_flag = self::KEYS['not_processed_flag'] . $attachment_id; // Image might have already passed through this filter. if ( strpos( $url, $process_flag ) !== false ) { return $url; } $meta = wp_get_attachment_metadata( $attachment_id ); if ( ! isset( $meta['file'] ) ) { return $url; } $default_args = [ 'width' => 'auto', 'height' => 'auto', 'resize' => apply_filters( 'optml_default_crop', [] ), ]; $args = wp_parse_args( $args, $default_args ); // If this is not cropped, we constrain the dimensions to the original image. if ( empty( $args['resize'] ) && ! in_array( 'auto', [ $args['width'], $args['height'] ], true ) ) { $dimensions = wp_constrain_dimensions( $meta['width'], $meta['height'], $args['width'], $args['height'] ); $args['width'] = $dimensions[0]; $args['height'] = $dimensions[1]; } $file = $meta['file']; if ( self::is_uploaded_image( $file ) ) { $optimized_url = $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] ); return strpos( $optimized_url, $process_flag ) === false ? str_replace( '/' . ltrim( $file, '/' ), '/' . $process_flag . $file, $optimized_url ) : $optimized_url; } else { // this is for the users that already offloaded the images before the other fixes $local_file = get_attached_file( $attachment_id ); if ( ! file_exists( $local_file ) ) { $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id ); if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) { foreach ( $duplicated_images as $id ) { if ( ! empty( $id ) ) { $duplicated_meta = wp_get_attachment_metadata( $id ); if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) { return $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] ); } } } } } } return $url; } /** * Replace the URLs in the editor content with the offloaded ones. * * @param string $content The incoming content. * * @return string */ public function replace_urls_in_editor_content( $content ) { $raw_extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content ); if ( empty( $raw_extracted ) ) { return $content; } $to_replace = []; foreach ( $raw_extracted as $url ) { $attachment = $this->get_local_attachement_id_from_url( $url ); // No local attachment. if ( $attachment['attachment_id'] === 0 ) { if ( $this->can_replace_url( $url ) ) { $to_replace[ $url ] = $this->get_optimized_image_url( $url, 'auto', 'auto' ); } continue; } $attachment_id = $attachment['attachment_id']; // Not offloaded. if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) { continue; } // Get W/H from url. $size = $this->parse_dimensions_from_filename( $url ); $width = $size[0] !== false ? $size[0] : 'auto'; $height = $size[1] !== false ? $size[1] : 'auto'; // Handle resize. $sizes2crop = self::size_to_crop(); $resize = apply_filters( 'optml_default_crop', [] ); $sizes = image_get_intermediate_size( $attachment_id, $size ); if ( false !== $sizes ) { if ( isset( $sizes2crop[ $width . $height ] ) ) { $resize = $this->to_optml_crop( $sizes2crop[ $width . $height ] ); } } // Build the optimized URL. $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 ); // Drop any image size from the URL. $optimized_url = str_replace( '-' . $width . 'x' . $height, '', $optimized_url ); $to_replace[ $url ] = $optimized_url; } return str_replace( array_keys( $to_replace ), array_values( $to_replace ), $content ); } /** * Replaces the post content URLs to use Offloaded ones on editor fetch. * * @param \WP_REST_Response $response The response object. * @param \WP_Post $post The post object. * @param \WP_REST_Request $request The request object. * * @return \WP_REST_Response */ public function pre_filter_rest_content( \WP_REST_Response $response, \WP_Post $post, \WP_REST_Request $request ) { $context = $request->get_param( 'context' ); if ( $context !== 'edit' ) { return $response; } $data = $response->get_data(); // Actually replace all URLs. $data['content']['raw'] = $this->replace_urls_in_editor_content( $data['content']['raw'] ); $response->set_data( $data ); return $response; } /** * Legacy function to be used for WordPress versions under 6.0.0. * * @param array $post_data Slashed, sanitized, processed post data. * @param array $postarr Slashed sanitized post data. * @param array $unsanitized_postarr Un-sanitized post data. * * @return array */ public function legacy_filter_saved_data( $post_data, $postarr, $unsanitized_postarr ) { return $this->filter_saved_data( $post_data, $postarr, $unsanitized_postarr, true ); } /** * Filter post content to use local attachments when saving offloaded images. * * @param array $post_data Slashed, sanitized, processed post data. * @param array $postarr Slashed sanitized post data. * @param array $unsanitized_postarr Un-sanitized post data. * @param bool $update Whether this is an existing post being updated or not. * * @return array */ public function filter_saved_data( $post_data, $postarr, $unsanitized_postarr, $update ) { if ( $postarr['post_status'] === 'trash' ) { return $post_data; } $content = $post_data['post_content']; $extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content ); $replace = []; foreach ( $extracted as $idx => $url ) { $id = self::get_attachment_id_from_url( $url ); if ( $id === false ) { continue; } $id = (int) $id; if ( $this->is_legacy_offloaded_attachment( $id ) ) { continue; } $original = self::get_original_url( $id ); if ( $original === false ) { continue; } $replace[ $url ] = $original; $size = $this->parse_dimension_from_optimized_url( $url ); if ( $size[0] === 'auto' || $size[1] === 'auto' ) { continue; } $extension = $this->get_ext( $url ); $metadata = wp_get_attachment_metadata( $id ); // Is this the full URL. if ( $metadata['width'] === (int) $size[0] && $metadata['height'] === (int) $size[1] ) { continue; } $size_crop_map = self::size_to_crop(); $crop = false; if ( isset( $size_crop_map[ $size[0] . $size[1] ] ) ) { $crop = $size_crop_map[ $size[0] . $size[1] ]; } if ( $crop ) { $width = $size[0]; $height = $size[1]; } else { // In case of an image size, we need to calculate the new dimensions for the proper file path. $constrained = wp_constrain_dimensions( $metadata['width'], $metadata['height'], $size[0], $size[1] ); $width = $constrained[0]; $height = $constrained[1]; } $replace[ $url ] = $this->maybe_strip_scaled( $replace[ $url ] ); $suffix = sprintf( '-%sx%s.%s', $width, $height, $extension ); $replace[ $url ] = str_replace( '.' . $extension, $suffix, $replace[ $url ] ); } $post_data['post_content'] = str_replace( array_keys( $replace ), array_values( $replace ), $content ); return $post_data; } /** * Alter the image size for the image widget. * * @param string $html the attachment image HTML string. * @param array $settings Control settings. * @param string $image_size_key Optional. Settings key for image size. * Default is `image`. * @param string $image_key Optional. Settings key for image. Default * is null. If not defined uses image size key * as the image key. * * @return string */ public function alter_elementor_image_size( $html, $settings, $image_size_key, $image_key ) { if ( ! isset( $settings['image'] ) ) { return $html; } $image = $settings['image']; if ( ! isset( $image['id'] ) ) { return $html; } if ( ! $this->is_new_offloaded_attachment( $image['id'] ) ) { return $html; } if ( ! isset( $settings['image_size'] ) ) { return $html; } if ( $settings['image_size'] === 'custom' ) { if ( ! isset( $settings['image_custom_dimension'] ) ) { return $html; } $custom_dimensions = $settings['image_custom_dimension']; if ( ! isset( $custom_dimensions['width'] ) || ! isset( $custom_dimensions['height'] ) ) { return $html; } $new_args = [ 'width' => $custom_dimensions['width'], 'height' => $custom_dimensions['height'], 'resize' => $this->to_optml_crop( true ), ]; $new_url = $this->get_new_offloaded_attachment_url( $image['url'], $image['id'], $new_args ); return str_replace( $image['url'], $new_url, $html ); } return $html; } /** * Adds new actions for new offloads. * * @return void */ public function add_new_actions() { add_filter( 'wp_prepare_attachment_for_js', [ self::$instance, 'alter_attachment_for_js' ], 999, 3 ); add_filter( 'wp_get_attachment_metadata', [ self::$instance, 'alter_attachment_metadata' ], 10, 2 ); add_filter( 'wp_get_attachment_image_src', [ self::$instance, 'alter_attachment_image_src' ], 10, 4 ); // Needed for rendering beaver builder css properly. add_filter( 'fl_builder_render_css', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 ); // Filter saved data on insert to use local attachments. // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter. $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' ); if ( $below_6_0_0 ) { add_filter( 'wp_insert_post_data', [ self::$instance, 'legacy_filter_saved_data' ], 10, 3 ); } else { add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_saved_data' ], 10, 4 ); } // Filter loaded data in the editors to use local attachments. add_filter( 'content_edit_pre', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 ); add_action( 'init', function () { $types = get_post_types_by_support( 'editor' ); foreach ( $types as $type ) { $post_type = get_post_type_object( $type ); if ( property_exists( $post_type, 'show_in_rest' ) && true === $post_type->show_in_rest ) { add_filter( 'rest_prepare_' . $type, [ self::$instance, 'pre_filter_rest_content' ], 10, 3 ); } } }, PHP_INT_MAX ); add_filter( 'get_attached_file', [ $this, 'alter_attached_file_response' ], 10, 2 ); add_filter( 'elementor/image_size/get_attachment_image_html', [ $this, 'alter_elementor_image_size', ], 10, 4 ); } /** * Elementor checks if the file exists before requesting a specific image size. * * Needed because otherwise there won't be any width/height on the `img` tags, breaking lazyload. * * Also needed because some * * @param string $file The file path. * @param int $id The attachment ID. * * @return bool|string */ public function alter_attached_file_response( $file, $id ) { if ( ! $this->is_new_offloaded_attachment( $id ) ) { return $file; } $metadata = wp_get_attachment_metadata( $id ); if ( isset( $metadata['file'] ) ) { $uploads = wp_get_upload_dir(); return $uploads['basedir'] . '/' . $metadata['file']; } return true; } /** * Maybe strip the `-scaled` from the URL. * * @param string $url The url. * * @return string */ public function maybe_strip_scaled( $url ) { $ext = $this->get_ext( $url ); return str_replace( '-scaled.' . $ext, '.' . $ext, $url ); } /** * Is it a PHPUnit test run. * * @return bool */ public static function is_phpunit_test() { return defined( 'OPTML_PHPUNIT_TESTING' ) && OPTML_PHPUNIT_TESTING === true; } /** * Get offloaded image attachment URL based on the given attachment ID and URL. * * @param mixed $attachment_id The attachment ID. * @param string $url The attachment URL. * * @return string */ private function get_offloaded_attachment_url( $attachment_id, $url ) { if ( ! $this->settings->is_offload_enabled() || ! is_numeric( $attachment_id ) ) { return $url; } elseif ( empty( $attachment_id ) && strpos( $url, self::KEYS['not_processed_flag'] ) !== false ) { $attachment_id = (int) self::get_attachment_id_from_url( $url ); } elseif ( empty( $attachment_id ) ) { $attachment_id = $this->attachment_url_to_post_id( $url ); } if ( $attachment_id > 0 || ! empty( get_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ) ) ) { $url = wp_get_attachment_metadata( $attachment_id )['file']; } return $url; } }