PluginProbe
Image Optimizer – Compress Images and Convert to WebP or AVIF / 1.5.1
Image Optimizer – Compress Images and Convert to WebP or AVIF v1.5.1
1.7.7 1.7.6 1.7.5 1.7.4 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.2.0 1.2.1 1.3.0 1.4.0 1.4.1 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 All 33 releases
image-optimization / modules / optimization / classes / optimize-image.php

optimize-image.php in Image Optimizer – Compress Images and Convert to WebP or AVIF 1.5.1, at modules/optimization/classes/optimize-image.php

379 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ImageOptimization\Modules\Optimization\Classes;
4
5 use ImageOptimization\Classes\File_System\{
6 Exceptions\File_System_Operation_Error,
7 File_System,
8 };
9 use ImageOptimization\Classes\File_Utils;
10 use ImageOptimization\Classes\Image\{
11 Exceptions\Image_Backup_Creation_Error,
12 Exceptions\Invalid_Image_Exception,
13 Image,
14 Image_Backup,
15 Image_Conversion,
16 Image_DB_Update,
17 Image_Meta,
18 Image_Status,
19 WP_Image_Meta};
20 use ImageOptimization\Classes\Logger;
21 use ImageOptimization\Classes\Utils;
22 use ImageOptimization\Classes\Exceptions\Quota_Exceeded_Error;
23 use ImageOptimization\Modules\Oauth\Components\Connect;
24 use ImageOptimization\Modules\Optimization\Classes\{
25 Exceptions\Image_File_Already_Exists_Error,
26 Exceptions\Image_Optimization_Error,
27 Exceptions\Bulk_Token_Expired_Error,
28 Exceptions\Image_Already_Optimized_Error,
29 };
30 use ImageOptimization\Modules\Settings\Classes\Settings;
31 use Throwable;
32
33 use ImageOptimization\Plugin;
34
35 if ( ! defined( 'ABSPATH' ) ) {
36 exit; // Exit if accessed directly.
37 }
38
39 /**
40 * The class is responsible for the optimization logic itself. It gets an image file, runs
41 * backup process if needed, sends a file to the service, stores the result and updates image meta.
42 *
43 * This class is used by manual, bulk and on-upload optimization flows.
44 */
45 class Optimize_Image {
46 private const IMAGE_OPTIMIZE_ENDPOINT = 'image/optimize';
47
48 protected ?Image $image;
49 protected WP_Image_Meta $wp_meta;
50 protected string $initiator;
51 protected ?string $bulk_token;
52 private string $current_image_path;
53 private string $current_image_size;
54 private bool $keep_backups;
55 private array $current_size_duplicates;
56 protected Image_Conversion $image_conversion;
57
58 /**
59 * @throws Quota_Exceeded_Error|Bulk_Token_Expired_Error|Image_File_Already_Exists_Error|Image_Optimization_Error
60 */
61 public function optimize(): void {
62 $sizes_enabled = Settings::get( Settings::CUSTOM_SIZES_OPTION_NAME );
63 $sizes_exist = $this->wp_meta->get_size_keys();
64
65 Logger::log(
66 Logger::LEVEL_INFO,
67 "Start optimization of {$this->image->get_id()}"
68 );
69
70 foreach ( $sizes_exist as $size_exist ) {
71 // If some image sizes optimization is disabled in settings, we check if the current one is still enabled
72 if (
73 'all' !== $sizes_enabled &&
74 Image::SIZE_FULL !== $size_exist &&
75 ! in_array( $size_exist, $sizes_enabled, true )
76 ) {
77 continue;
78 }
79
80 // Elementor editor generates thumbnails we don't need to optimize.
81 if ( str_starts_with( $size_exist, 'elementor_' ) ) {
82 continue;
83 }
84
85 $image_meta = new Image_Meta( $this->image->get_id() );
86
87 // If the current size was already optimized -- ignore it.
88 if ( in_array( $size_exist, $image_meta->get_optimized_sizes(), true ) ) {
89 Logger::log(
90 Logger::LEVEL_INFO,
91 "Size `$size_exist` is already optimized"
92 );
93
94 continue;
95 }
96
97 if ( ! file_exists( $this->image->get_file_path( $size_exist ) ) ) {
98 Logger::log(
99 Logger::LEVEL_ERROR,
100 "Can't access file for size `$size_exist`"
101 );
102
103 throw new Image_Optimization_Error( esc_html__( 'File is missing. Verify the upload', 'image-optimization' ) );
104 }
105
106 $this->current_image_size = $size_exist;
107 $this->current_size_duplicates = $this->wp_meta->get_size_duplicates( $size_exist );
108 $this->current_image_path = $this->image->get_file_path( $size_exist );
109
110 $this->optimize_current_size();
111
112 $this->current_image_size = '';
113 $this->current_size_duplicates = [];
114 $this->current_image_path = '';
115 }
116
117 $this->mark_as_optimized();
118
119 if ( ! $this->keep_backups ) {
120 Image_Backup::remove( $this->image->get_id() );
121 }
122
123 Logger::log(
124 Logger::LEVEL_INFO,
125 "End optimization of {$this->image->get_id()}"
126 );
127 }
128
129 private function optimize_current_size(): void {
130 try {
131 $original_path = $this->current_image_path;
132 $response = $this->send_file();
133
134 $optimized_size = (int) $response->optimizedSize; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
135 $optimized_image_binary = base64_decode( $response->image, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
136
137 $this->replace_image_file( $optimized_image_binary );
138 $this->update_attachment_meta( $optimized_size );
139
140 if ( $original_path !== $this->current_image_path ) {
141 $this->update_posts( $original_path, $this->current_image_path );
142 }
143
144 // This should only be updated after meta
145 $this->update_attachment_post();
146 } catch ( Image_Already_Optimized_Error $iao ) {
147 // If we can't optimize it further, just file update the meta
148 $original_size = $this->wp_meta->get_file_size( $this->current_image_size )
149 ?? File_System::size( $this->image->get_file_path( $this->current_image_size ) );
150
151 $this->update_attachment_meta( $original_size );
152 $this->update_attachment_post();
153 } catch ( Bulk_Token_Expired_Error |Quota_Exceeded_Error |Image_File_Already_Exists_Error $e ) {
154 throw $e;
155 } catch ( Throwable $t ) {
156 // In case of anything else
157 throw new Image_Optimization_Error( $t->getMessage() );
158 }
159 }
160
161 private function send_file() {
162 $connect_status = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->get_connect_status();
163 $headers = [
164 'access_token' => $connect_status->access_token ?? '',
165 ];
166
167 if ( $this->bulk_token ) {
168 $headers['x-elementor-bulk-token'] = $this->bulk_token;
169 }
170
171 $optimization_options = [
172 'compression_level' => Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ),
173 'convert_to_format' => $this->image_conversion->get_current_conversion_option(),
174 'strip_exif' => Settings::get( Settings::STRIP_EXIF_METADATA_OPTION_NAME ),
175 ];
176
177 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
178 $optimization_options['resize'] = Settings::get( Settings::RESIZE_LARGER_IMAGES_SIZE_OPTION_NAME );
179 }
180
181 $image_key = wp_generate_password( 15, false );
182
183 $response = Utils::get_api_client()->make_request(
184 'POST',
185 self::IMAGE_OPTIMIZE_ENDPOINT,
186 [
187 'initiator' => $this->initiator,
188 'image_url' => $this->image->get_url( $this->current_image_size ),
189 'image_key' => $image_key,
190 'checksum' => md5_file( $this->current_image_path ),
191 'attachment_id' => $this->image->get_id(),
192 'attachment_parent_id' => Image::SIZE_FULL === $this->current_image_size ? 0 : $this->image->get_id(),
193 'image_optimization_settings' => base64_encode( wp_json_encode( $optimization_options ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
194 ],
195 $headers,
196 $this->current_image_path,
197 'image'
198 );
199
200 if ( isset( $response->stats ) ) {
201 Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->update_usage_data( $response->stats );
202 }
203
204 if ( ! isset( $response->imageKey ) || $image_key !== $response->imageKey ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
205 Logger::log(
206 Logger::LEVEL_ERROR,
207 "Image key must be $image_key, instead got $response->imageKey"
208 );
209
210 throw new Image_Optimization_Error( esc_html__( 'Service response is incorrect', 'image-optimization' ) );
211 }
212
213 $received_file_hash = md5( base64_decode( $response->image, true ) );
214
215 if ( ! isset( $response->checksum ) || $received_file_hash !== $response->checksum ) {
216 Logger::log(
217 Logger::LEVEL_ERROR,
218 "Image key must be $response->checksum, instead calculated $received_file_hash"
219 );
220
221 throw new Image_Optimization_Error( esc_html__( 'Service response is incorrect', 'image-optimization' ) );
222 }
223
224 return $response;
225 }
226
227 /**
228 * @throws File_System_Operation_Error|Image_Backup_Creation_Error|Image_File_Already_Exists_Error
229 */
230 private function replace_image_file( string $file_data ): void {
231 $path = $this->current_image_path;
232
233 // If we have backups disabled, we want to make sure we can download and save new file before we
234 // remove an existing one.
235 $tmp_path = File_Utils::replace_extension( $path, 'tmp' );
236
237 File_System::put_contents( $tmp_path, $file_data );
238
239 if ( $this->image_conversion->is_enabled() ) {
240 $converted_path = File_Utils::replace_extension( $tmp_path, $this->image_conversion->get_current_file_extension() );
241 $original_is_already_converted = strtolower( $path ) === strtolower( $converted_path );
242
243 if ( ! $original_is_already_converted && File_System::exists( $converted_path ) ) {
244 throw new Image_File_Already_Exists_Error();
245 }
246
247 // We want to keep both original as a fallback and optimized webp to prevent 404s
248 if ( ! $original_is_already_converted ) {
249 $this->keep_backups = true;
250
251 ( new Image_Meta( $this->image->get_id() ) )
252 ->set_image_backup_path( $this->current_image_size, $this->current_image_path )
253 ->save();
254 }
255
256 if ( $original_is_already_converted ) {
257 Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
258 }
259
260 File_System::move( $tmp_path, $converted_path, true );
261
262 $path = $converted_path;
263 } else {
264 Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
265
266 File_System::move( $tmp_path, $path, true );
267 }
268
269 if ( Image::SIZE_FULL === $this->current_image_size ) {
270 // Drop WP caches
271 update_attached_file( $this->image->get_id(), $path );
272 }
273
274 // Updating to the correct value
275 $this->current_image_path = $path;
276 }
277
278 /**
279 * Updates attachment records in the `wp_posts` table.
280 *
281 * @return void
282 */
283 private function update_attachment_post() {
284 $update_query = [];
285
286 if ( $this->image_conversion->is_enabled() ) {
287 $attachment_object = $this->image->get_attachment_object();
288
289 $update_query['guid'] = File_Utils::replace_extension(
290 $attachment_object->guid,
291 $this->image_conversion->get_current_file_extension()
292 );
293
294 $update_query['post_mime_type'] = $this->image_conversion->get_current_mime_type();
295 }
296
297 $update_query['post_modified'] = current_time( 'mysql' );
298 $update_query['post_modified_gmt'] = current_time( 'mysql', true );
299
300 $this->image->update_attachment( $update_query );
301 }
302
303 /**
304 * Updates attachment records in the `wp_postmeta` table.
305 *
306 * @param int $optimized_size
307 *
308 * @return void
309 */
310 private function update_attachment_meta( int $optimized_size ) {
311 $meta = new Image_Meta( $this->image->get_id() );
312
313 list($width, $height) = getimagesize( $this->current_image_path );
314
315 $sizes_to_update = [ $this->current_image_size, ...$this->current_size_duplicates ];
316
317 foreach ( $sizes_to_update as $size ) {
318 $meta
319 ->set_compression_level( Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ) )
320 ->add_optimized_size( $size )
321 ->add_original_data( $size, $this->wp_meta->get_size_data( $size ) );
322
323 $this->wp_meta
324 ->set_width( $size, $width )
325 ->set_height( $size, $height )
326 ->set_file_size( $size, $optimized_size );
327
328 if ( $this->image_conversion->is_enabled() ) {
329 $this->wp_meta
330 ->set_file_path( $size, $this->current_image_path )
331 ->set_mime_type( $size, $this->image_conversion->get_current_mime_type() );
332 }
333 }
334
335 $meta->save();
336 $this->wp_meta->save();
337 }
338
339 /**
340 * If we change an image extension, we should walk through the wp_posts table and update all the
341 * hardcoded image links to prevent 404s.
342 *
343 * @param string $old_path Previous image path
344 * @param string $new_path Current image path
345 *
346 * @return void
347 */
348 private function update_posts( string $old_path, string $new_path ) {
349 Image_DB_Update::update_posts_table_urls(
350 File_Utils::get_url_from_path( $old_path ),
351 File_Utils::get_url_from_path( $new_path )
352 );
353 }
354
355 /**
356 * Changes image status after all image sizes were optimized.
357 *
358 * @return void
359 */
360 private function mark_as_optimized() {
361 ( new Image_Meta( $this->image->get_id() ) )
362 ->set_status( Image_Status::OPTIMIZED )
363 ->set_error_type( null )
364 ->save();
365 }
366
367 /**
368 * @throws Invalid_Image_Exception
369 */
370 public function __construct( int $image_id, string $initiator, ?string $bulk_token = null ) {
371 $this->image = new Image( $image_id );
372 $this->wp_meta = new WP_Image_Meta( $image_id, $this->image );
373 $this->initiator = $initiator;
374 $this->bulk_token = $bulk_token;
375 $this->image_conversion = new Image_Conversion();
376 $this->keep_backups = Settings::get( Settings::BACKUP_ORIGINAL_IMAGES_OPTION_NAME );
377 }
378 }
379