Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 289
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageService
0.00% covered (danger)
0.00%
0 / 289
0.00% covered (danger)
0.00%
0 / 10
2652
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 optimize_image
0.00% covered (danger)
0.00%
0 / 144
0.00% covered (danger)
0.00%
0 / 1
342
 ban_site
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 generate_webp_file_path
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 save_file
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 get_response_message
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 replace_original_with_webp
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
182
 register_webp_as_new_media
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 delete_original_file
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_monthly_usage_limit
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Images;
4
5use NewfoldLabs\WP\Module\Performance\Data\Events;
6use NewfoldLabs\WP\Module\Performance\Services\EventService;
7
8/**
9 * Optimizes images using a Cloudflare Worker and saves them locally.
10 */
11class ImageService {
12
13    /**
14     * Dependency injection container.
15     *
16     * @var \NewfoldLabs\WP\Container\Container
17     */
18    protected $container;
19
20    /**
21     * Constructor.
22     *
23     * @param \NewfoldLabs\WP\Container\Container $container Dependency injection container.
24     */
25    public function __construct( $container ) {
26        $this->container = $container;
27    }
28
29    /**
30     * Cloudflare Worker URL for image optimization.
31     */
32    private const WORKER_URL = 'https://hiive.cloud/workers/image-optimization';
33
34    /**
35     * Rate limit transient key.
36     *
37     * @var string
38     */
39    public static $rate_limit_transient_key = 'nfd_image_optimization_rate_limit';
40
41    /**
42     * Optimizes an uploaded image by sending it to the Cloudflare Worker and saving the result as WebP.
43     *
44     * @param string $image_url The URL of the uploaded image.
45     * @param string $original_file_path The original file path of the uploaded image.
46     * @return string|WP_Error The path to the optimized WebP file or a WP_Error on failure.
47     */
48    public function optimize_image( $image_url, $original_file_path ) {
49        // Validate the image URL
50        if ( empty( $image_url ) || ! filter_var( $image_url, FILTER_VALIDATE_URL ) ) {
51            return new \WP_Error(
52                'nfd_performance_error',
53                __( 'The provided image URL is invalid.', 'wp-module-performance' )
54            );
55        }
56
57        $site_url = get_site_url();
58        if ( ! $site_url ) {
59            return new \WP_Error(
60                'nfd_performance_error',
61                __( 'Error retrieving site URL.', 'wp-module-performance' )
62            );
63        }
64
65        // Check if the site is permanently banned
66        if ( ImageSettings::is_banned() ) {
67            return new \WP_Error(
68                'nfd_performance_error',
69                __( 'This site no longer qualifies for image optimization as it has reached its usage limits.', 'wp-module-performance' )
70            );
71        }
72
73        // Check for rate limiting
74        $rate_limit_transient = get_transient( self::$rate_limit_transient_key );
75        if ( $rate_limit_transient ) {
76            return new \WP_Error(
77                'nfd_performance_error',
78                sprintf(
79                /* translators: %s: Retry time in seconds */
80                    __( 'This site has made too many requests in a short period. Please wait %s before trying again.', 'wp-module-performance' ),
81                    human_time_diff( time(), $rate_limit_transient )
82                )
83            );
84        }
85
86        EventService::send(
87            array(
88                'category' => Events::get_category()[0],
89                'action'   => 'image_transformation_requested',
90                'data'     => array(
91                    'image_url' => $image_url,
92                ),
93            )
94        );
95
96        // Make a POST request to the Cloudflare Worker
97        $response = wp_remote_post(
98            self::WORKER_URL . '/?image=' . rawurlencode( $image_url ),
99            array(
100                'method'  => 'POST',
101                'timeout' => 30,
102                'headers' => array(
103                    'X-Site-Url' => $site_url,
104                ),
105            )
106        );
107
108        // Update the stored monthly usage data.
109        $monthly_request_count = wp_remote_retrieve_header( $response, 'X-Monthly-Request-Count' );
110        $monthly_limit         = wp_remote_retrieve_header( $response, 'X-Monthly-Limit' );
111        $monthly_request_count = ( '' !== $monthly_request_count ) ? intval( $monthly_request_count ) : null;
112        $monthly_limit         = ( '' !== $monthly_limit ) ? intval( $monthly_limit ) : null;
113        if ( null !== $monthly_request_count && null !== $monthly_limit ) {
114            $settings                  = ImageSettings::get( $this->container, true );
115            $settings['monthly_usage'] = array(
116                'monthlyRequestCount' => $monthly_request_count,
117                'maxRequestsPerMonth' => $monthly_limit,
118            );
119            ImageSettings::update( $settings, $this->container );
120        }
121
122        // Handle errors from the HTTP request
123        if ( is_wp_error( $response ) ) {
124            EventService::send(
125                array(
126                    'category' => Events::get_category()[0],
127                    'action'   => 'image_transformation_failed',
128                    'data'     => array(
129                        'image_url' => $image_url,
130                        'error'     => $response->get_error_message(),
131                    ),
132                )
133            );
134            return new \WP_Error(
135                'nfd_performance_error',
136                sprintf(
137                /* translators: %s: Error message */
138                    __( 'Error connecting to Cloudflare Worker: %s', 'wp-module-performance' ),
139                    $response->get_error_message()
140                )
141            );
142        }
143
144        // Check for HTTP errors
145        $response_code = wp_remote_retrieve_response_code( $response );
146        if ( 403 === $response_code ) {
147            // If worker indicates a permanent ban, ban the site
148            $this->ban_site();
149            EventService::send(
150                array(
151                    'category' => Events::get_category()[0],
152                    'action'   => 'image_transformation_failed',
153                    'data'     => array(
154                        'image_url' => $image_url,
155                        'error'     => __( 'Image optimization access has been permanently revoked for this site.', 'wp-module-performance' ),
156                    ),
157                )
158            );
159            return new \WP_Error(
160                'nfd_performance_error',
161                __( 'Image optimization access has been permanently revoked for this site.', 'wp-module-performance' )
162            );
163        } elseif ( 429 === $response_code ) {
164            // Set a transient for the retry period
165            $retry_after   = wp_remote_retrieve_header( $response, 'Retry-After' );
166            $retry_seconds = $retry_after ? intval( $retry_after ) : 60;
167            set_transient( self::$rate_limit_transient_key, time() + $retry_seconds, $retry_seconds );
168            EventService::send(
169                array(
170                    'category' => Events::get_category()[0],
171                    'action'   => 'image_transformation_failed',
172                    'data'     => array(
173                        'image_url' => $image_url,
174                        'error'     => __( 'Rate limit exceeded. Please try again later.', 'wp-module-performance' ),
175                    ),
176                )
177            );
178            return new \WP_Error(
179                'nfd_performance_error',
180                __( 'Rate limit exceeded. Please try again later.', 'wp-module-performance' )
181            );
182        }
183
184        $optimized_image_body = wp_remote_retrieve_body( $response );
185        $content_type         = wp_remote_retrieve_header( $response, 'content-type' );
186        if ( empty( $optimized_image_body ) || 'image/webp' !== $content_type ) {
187            $error_message = $this->get_response_message( $response ) ?? __( 'Invalid response from Cloudflare Worker.', 'wp-module-performance' );
188            EventService::send(
189                array(
190                    'category' => Events::get_category()[0],
191                    'action'   => 'image_transformation_failed',
192                    'data'     => array(
193                        'image_url' => $image_url,
194                        'error'     => $error_message,
195                    ),
196                )
197            );
198            return new \WP_Error(
199                'nfd_performance_error',
200                $error_message
201            );
202        }
203
204        EventService::send(
205            array(
206                'category' => Events::get_category()[0],
207                'action'   => 'image_transformation_completed',
208                'data'     => array(
209                    'image_url' => $image_url,
210                ),
211            )
212        );
213
214        // Save the WebP image to the same directory as the original file
215        $webp_file_path = $this->generate_webp_file_path( $original_file_path );
216        if ( is_wp_error( $webp_file_path ) ) {
217            return $webp_file_path;
218        }
219        if ( true !== $this->save_file( $webp_file_path, $optimized_image_body ) ) {
220            return new \WP_Error(
221                'nfd_performance_error',
222                __( 'Failed to save the optimized WebP image.', 'wp-module-performance' )
223            );
224        }
225
226        return $webp_file_path;
227    }
228
229    /**
230     * Permanently ban the site from accessing image optimization.
231     */
232    private function ban_site() {
233        $settings                      = ImageSettings::get( $this->container, true );
234        $settings['banned_status']     = true;
235        $settings['bulk_optimization'] = false;
236        $settings['auto_optimized_uploaded_images']['enabled']                    = false;
237        $settings['auto_optimized_uploaded_images']['auto_delete_original_image'] = false;
238        ImageSettings::update( $settings, $this->container );
239    }
240
241    /**
242     * Generates a WebP file path based on the original file path.
243     *
244     * @param string $original_file_path The original file path.
245     * @return string|WP_Error The WebP file path or a WP_Error on failure.
246     */
247    private function generate_webp_file_path( $original_file_path ) {
248        $path_info = pathinfo( $original_file_path );
249
250        if ( ! isset( $path_info['dirname'], $path_info['filename'] ) ) {
251            return new \WP_Error(
252                'nfd_performance_error',
253                __( 'Invalid file path for generating WebP.', 'wp-module-performance' )
254            );
255        }
256
257        return $path_info['dirname'] . '/' . $path_info['filename'] . '.webp';
258    }
259
260    /**
261     * Saves the content to a file.
262     *
263     * @param string $file_path The path where the file will be saved.
264     * @param string $content The content to save.
265     * @return bool True on success, false on failure.
266     */
267    private function save_file( $file_path, $content ) {
268        global $wp_filesystem;
269
270        if ( ! $wp_filesystem ) {
271            require_once ABSPATH . 'wp-admin/includes/file.php';
272            WP_Filesystem();
273        }
274
275        if ( ! $wp_filesystem->put_contents( $file_path, $content, FS_CHMOD_FILE ) ) {
276            return false;
277        }
278
279        return true;
280    }
281
282    /**
283     * Retrieves the response message from wp_remote_post response.
284     *
285     * @param array $response The HTTP response from wp_remote_post.
286     * @return string|null The response message or null if unavailable.
287     */
288    private function get_response_message( $response ) {
289        $code    = wp_remote_retrieve_response_code( $response );
290        $message = wp_remote_retrieve_response_message( $response );
291
292        if ( $code && $message ) {
293            return sprintf(
294                /* translators: 1: HTTP response code, 2: Response message */
295                __( 'HTTP %1$d: %2$s', 'wp-module-performance' ),
296                $code,
297                $message
298            );
299        }
300
301        return null;
302    }
303
304    /**
305     * Replaces the original file with the optimized WebP file in the Media Library.
306     *
307     * @param int|string $media_id_or_path Media ID or original file path.
308     * @param string     $webp_file_path   The path to the optimized WebP file.
309     * @return array|WP_Error The updated upload array or WP_Error on failure.
310     */
311    public function replace_original_with_webp( $media_id_or_path, $webp_file_path ) {
312        $original_file_path = '';
313        $upload_dir         = wp_upload_dir();
314        $webp_file_url      = trailingslashit( $upload_dir['url'] ) . wp_basename( $webp_file_path );
315
316        // Ensure the WebP file exists
317        if ( ! file_exists( $webp_file_path ) || filesize( $webp_file_path ) === 0 ) {
318            return new \WP_Error(
319                'nfd_performance_error',
320                __( 'WebP file is missing or empty.', 'wp-module-performance' )
321            );
322        }
323
324        // Determine if $media_id_or_path is a Media ID or file path
325        if ( is_numeric( $media_id_or_path ) && (int) $media_id_or_path > 0 ) {
326            // Media ID provided
327            $original_file_path = get_attached_file( $media_id_or_path );
328            if ( ! $original_file_path ) {
329                return new \WP_Error(
330                    'nfd_performance_error',
331                    __( 'Invalid Media ID provided.', 'wp-module-performance' )
332                );
333            }
334        } elseif ( is_string( $media_id_or_path ) && file_exists( $media_id_or_path ) ) {
335            // File path provided
336            $original_file_path = $media_id_or_path;
337
338            // Store metadata in a transient for later use
339            $transient_key = 'nfd_webp_metadata_' . md5( $webp_file_path );
340            set_transient(
341                $transient_key,
342                array(
343                    'webp_file_path'     => $webp_file_path,
344                    'original_file_path' => $original_file_path,
345                ),
346                HOUR_IN_SECONDS
347            );
348        } else {
349            return new \WP_Error(
350                'nfd_performance_error',
351                __( 'Invalid Media ID or file path provided.', 'wp-module-performance' )
352            );
353        }
354
355        // Delete the original file from disk
356        if ( ! $this->delete_original_file( $original_file_path ) ) {
357            return new \WP_Error(
358                'nfd_performance_error',
359                __( 'Failed to delete the original file.', 'wp-module-performance' )
360            );
361        }
362
363        // If Media ID is available, update its metadata
364        if ( is_numeric( $media_id_or_path ) && $media_id_or_path > 0 ) {
365            // Update the file path in the Media Library
366            update_attached_file( $media_id_or_path, $webp_file_path );
367
368            // Regenerate and update attachment metadata
369            require_once ABSPATH . 'wp-admin/includes/image.php';
370            $metadata = wp_generate_attachment_metadata( $media_id_or_path, $webp_file_path );
371
372            if ( is_wp_error( $metadata ) || empty( $metadata ) ) {
373                return new \WP_Error(
374                    'nfd_performance_error',
375                    __( 'Failed to generate attachment metadata.', 'wp-module-performance' )
376                );
377            }
378
379            wp_update_attachment_metadata( $media_id_or_path, $metadata );
380
381            // Update the MIME type to reflect WebP
382            $post_data = array(
383                'ID'             => $media_id_or_path,
384                'post_mime_type' => 'image/webp',
385            );
386            wp_update_post( $post_data );
387
388            // Save metadata for optimized image
389            update_post_meta( $media_id_or_path, '_nfd_performance_image_optimized', 1 );
390        }
391
392        // Return the updated upload array
393        return array(
394            'file' => $webp_file_path,
395            'url'  => $webp_file_url,
396            'type' => 'image/webp',
397        );
398    }
399
400    /**
401     * Registers the WebP file as a standalone media item in the Media Library.
402     *
403     * @param string $webp_file_path The path to the optimized WebP file.
404     * @return int|WP_Error The attachment ID of the new media item, or WP_Error on failure.
405     */
406    public function register_webp_as_new_media( $webp_file_path ) {
407        // Prepare the attachment data
408        $attachment_data = array(
409            'post_mime_type' => 'image/webp',
410            'post_title'     => wp_basename( $webp_file_path ),
411            'post_content'   => '',
412            'post_status'    => 'inherit',
413        );
414
415        // Insert the WebP file as a new attachment
416        $attachment_id = wp_insert_attachment( $attachment_data, $webp_file_path );
417
418        if ( is_wp_error( $attachment_id ) ) {
419            return $attachment_id;
420        }
421
422        // Generate and update attachment metadata
423        require_once ABSPATH . 'wp-admin/includes/image.php';
424        $metadata = wp_generate_attachment_metadata( $attachment_id, $webp_file_path );
425        wp_update_attachment_metadata( $attachment_id, $metadata );
426
427        // Save metadata for optimized image
428        update_post_meta( $attachment_id, '_nfd_performance_image_optimized', 1 );
429
430        return $attachment_id;
431    }
432
433    /**
434     * Deletes the original uploaded file from the filesystem.
435     *
436     * @param string $file_path The path to the original file.
437     * @return bool True on success, false on failure.
438     */
439    public function delete_original_file( $file_path ) {
440        if ( file_exists( $file_path ) ) {
441            return wp_delete_file( $file_path );
442        }
443
444        return false;
445    }
446
447    /**
448     * Retrieves the monthly usage limit for image optimization from the Cloudflare Worker.
449     *
450     * @return array|WP_Error The monthly request count and limit, or a WP_Error on failure.
451     */
452    public function get_monthly_usage_limit() {
453        $site_url = get_site_url();
454        if ( ! $site_url ) {
455            return new \WP_Error(
456                'nfd_performance_error',
457                __( 'Error retrieving site URL.', 'wp-module-performance' )
458            );
459        }
460
461        // Make a GET request to the CF Worker to retrieve monthly usage
462        $response = wp_remote_get(
463            self::WORKER_URL . '/?monthly-count=true',
464            array(
465                'timeout' => 15,
466                'headers' => array(
467                    'X-Site-Url' => $site_url,
468                ),
469            )
470        );
471
472        // Handle HTTP errors
473        if ( is_wp_error( $response ) ) {
474            return new \WP_Error(
475                'nfd_performance_error',
476                sprintf(
477                    /* translators: %s: Error message */
478                    __( 'Error connecting to Cloudflare Worker: %s', 'wp-module-performance' ),
479                    $response->get_error_message()
480                )
481            );
482        }
483
484        // Parse response data
485        $response_code = wp_remote_retrieve_response_code( $response );
486        if ( 200 !== $response_code ) {
487            return new \WP_Error(
488                'nfd_performance_error',
489                sprintf(
490                    /* translators: %s: HTTP response code */
491                    __( 'Unexpected response from Cloudflare Worker: HTTP %s', 'wp-module-performance' ),
492                    $response_code
493                )
494            );
495        }
496
497        $body = json_decode( wp_remote_retrieve_body( $response ), true );
498        if ( ! is_array( $body ) || ! isset( $body['monthlyRequestCount'], $body['maxRequestsPerMonth'] ) ) {
499            return new \WP_Error(
500                'nfd_performance_error',
501                __( 'Invalid response from Cloudflare Worker.', 'wp-module-performance' )
502            );
503        }
504
505        $settings                  = ImageSettings::get( $this->container, false );
506        $settings['monthly_usage'] = $body;
507        ImageSettings::update( $settings, $this->container );
508
509        return $body;
510    }
511}