PluginProbe
Squeeze – Image Optimization & Compression, WEBP Conversion / 1.7.12
Squeeze – Image Optimization & Compression, WEBP Conversion v1.7.12
1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 trunk 1.0 1.1 1.2 1.3 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5 1.5.1 1.5.2 1.6 All 42 releases
squeeze / inc / helpers.php

helpers.php in Squeeze – Image Optimization & Compression, WEBP Conversion 1.7.12, at inc/helpers.php

1,009 lines 45.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SqueezeFree;
4
5 // Exit if accessed directly.
6 if ( !defined( 'ABSPATH' ) ) {
7 exit;
8 }
9 class SqueezeHelpers extends SqueezeInit {
10 // Cache squeeze_options array per request to avoid repeated database queries
11 private static $cached_squeeze_options = null;
12
13 /** @var string[]|null */
14 private static $cached_excluded_images = null;
15
16 public function __construct() {
17 //parent::__construct(); // will cause infinite loop in SquuezeInit
18 add_filter(
19 'posts_where',
20 [$this, 'get_images_from_last_id'],
21 10,
22 2
23 );
24 }
25
26 public function get_upload_path( $attach_id, $filename, $url ) {
27 if ( $attach_id > 0 ) {
28 $upload_dir = str_replace( $filename, "", wp_get_original_image_path( $attach_id ) );
29 } else {
30 $upload_url = str_replace( $filename, '', $url );
31 if ( strpos( $upload_url, '//' ) === 0 ) {
32 $upload_url = (( is_ssl() ? 'https:' : 'http:' )) . $upload_url;
33 }
34 $resolved = $this->resolve_media_url_to_abspath( $upload_url );
35 $upload_dir = ( $resolved !== '' ? $resolved : str_replace( home_url( '/' ), ABSPATH, $upload_url ) );
36 }
37 return str_replace( '/', DIRECTORY_SEPARATOR, $upload_dir );
38 }
39
40 public function create_backup_filename( $filename ) {
41 $backup_filename = preg_replace( "/(\\.(?!.*\\.))/", '.bak.', $filename );
42 return $backup_filename;
43 }
44
45 public function backup_original_image( $upload_path, $filename, $original_file = null ) {
46 if ( !function_exists( 'WP_Filesystem' ) ) {
47 require_once ABSPATH . 'wp-admin/includes/file.php';
48 }
49 global $wp_filesystem;
50 // Initialize the filesystem (this populates $wp_filesystem)
51 WP_Filesystem();
52 if ( !$wp_filesystem || !method_exists( $wp_filesystem, 'copy' ) ) {
53 return new \WP_Error('squeeze_filesystem_api_error', 'Filesystem API is not available or failed to initialize.');
54 }
55 $backup_filename = $this->create_backup_filename( $filename );
56 if ( !file_exists( $upload_path . $backup_filename ) ) {
57 try {
58 if ( !$original_file ) {
59 $upload_backup_file = $wp_filesystem->copy( $upload_path . $filename, $upload_path . $backup_filename, true );
60 } else {
61 $upload_backup_file = move_uploaded_file( $original_file, $upload_path . $backup_filename );
62 }
63 } catch ( \Exception $e ) {
64 return new \WP_Error('squeeze_backup_original_image_failed', '' . esc_html__( 'Backup original image failed', 'squeeze' ) . ': ' . $upload_path . $backup_filename);
65 }
66 }
67 return true;
68 }
69
70 public function decode_base64_image( $base64, $file_format ) {
71 $img = str_replace( 'data:image/' . $file_format . ';base64,', '', $base64 );
72 $img = str_replace( ' ', '+', $img );
73 return base64_decode( $img );
74 }
75
76 public function upload_image(
77 $upload_path,
78 $filename,
79 $decoded_image,
80 $is_file = false
81 ) {
82 if ( !function_exists( 'WP_Filesystem' ) ) {
83 require_once ABSPATH . 'wp-admin/includes/file.php';
84 }
85 global $wp_filesystem;
86 // Initialize the filesystem (this populates $wp_filesystem)
87 WP_Filesystem();
88 if ( !$wp_filesystem || !method_exists( $wp_filesystem, 'copy' ) ) {
89 return new \WP_Error('squeeze_filesystem_api_error', 'Filesystem API is not available or failed to initialize.');
90 }
91 if ( $is_file ) {
92 $upload_file = move_uploaded_file( $decoded_image, $upload_path . $filename );
93 } else {
94 $upload_file = $wp_filesystem->put_contents( $upload_path . $filename, $decoded_image );
95 }
96 if ( !$upload_file ) {
97 return new \WP_Error('squeeze_upload_image_failed', '' . esc_html__( 'Upload image failed', 'squeeze' ) . ': <br>upload_path: ' . $upload_path . '<br>filename: ' . $filename);
98 }
99 return $upload_file;
100 }
101
102 public function upload_image_thumbs(
103 $upload_path,
104 $sizes,
105 $file_format,
106 $filename = '',
107 $attach_id = 0
108 ) {
109 if ( !is_array( $sizes ) || empty( $sizes ) ) {
110 return new \WP_Error('squeeze_upload_image_thumbs_failed', '' . esc_html__( 'No image data found', 'squeeze' ));
111 }
112 if ( !function_exists( 'WP_Filesystem' ) ) {
113 require_once ABSPATH . 'wp-admin/includes/file.php';
114 }
115 global $wp_filesystem;
116 // Initialize the filesystem (this populates $wp_filesystem)
117 WP_Filesystem();
118 if ( !$wp_filesystem || !method_exists( $wp_filesystem, 'copy' ) ) {
119 return new \WP_Error('squeeze_filesystem_api_error', 'Filesystem API is not available or failed to initialize.');
120 }
121 $attach_meta = ( $attach_id > 0 ? wp_get_attachment_metadata( $attach_id ) : false );
122 $allowed_extensions = array_keys( self::ALLOWED_IMAGE_FORMATS );
123 $is_direct_webp = $this->get_option( 'direct_webp' );
124 foreach ( $sizes as $size_name => $size_data ) {
125 if ( $size_name === 'original' ) {
126 continue;
127 }
128 $new_size_url = '';
129 if ( $file_format === 'webp' && $is_direct_webp && $filename ) {
130 //$size_data['url'] = preg_replace('/\.[^.]+$/', '.webp', $size_data['url']);
131 // remove extension from filename if it exists
132 //$origin_filename = $filename;
133 $new_filename = pathinfo( $filename, PATHINFO_FILENAME );
134 // DO NOT appennd to the $filename, causes bug with complex filenames
135 // replace the filename in the URL before the last dash
136 // e.g. 'http://localhost/test/wp-content/uploads/2025/08/image-thumbxsize.jpg' becomes
137 // 'http://localhost/test/wp-content/uploads/2025/08/$new_filename-thumbxsize.webp'
138 // 1. Match and capture path, base filename, suffix, and extension
139 //if (preg_match('/^(.*\/)(.+)-([^-\/]+)\.((?:jpe?g|png))$/i', $size_data['url'], $m)) {
140 if ( preg_match( '#^(?P<path>.*/wp-content/uploads/.*/)(?P<base>[^/]+?)(?P<suffix>-(?:\\d+x\\d+|scaled|rotated))?\\.(?P<ext>jpe?g|png)$#i', $size_data['url'], $m ) ) {
141 // $m[1] = the full path including trailing slash
142 // $m[2] = the base filename (before the dash)
143 // $m[3] = the suffix (between dash and extension)
144 // $m[4] = the original extension
145 $path = $m[1];
146 //$new_filename = $m[2]; // <-- this is your variable
147 $suffix = $m[3];
148 // 2. Rebuild URL with .webp
149 $new_size_url = "{$path}{$new_filename}{$suffix}.webp";
150 }
151 }
152 $size_base64 = sanitize_text_field( $size_data['base64'] );
153 $size_decoded = $this->decode_base64_image( $size_base64, $file_format );
154 $size_filename = basename( sanitize_url( ( $new_size_url ? $new_size_url : $size_data['url'] ) ) );
155 // Prefer the WordPress attachment metadata filename when available.
156 $meta_size_key = ( $size_name === 'full' ? 'scaled' : $size_name );
157 if ( is_array( $attach_meta ) && !empty( $attach_meta['sizes'][$meta_size_key]['file'] ) ) {
158 $size_filename = basename( $attach_meta['sizes'][$meta_size_key]['file'] );
159 }
160 $size_filename = sanitize_file_name( $size_filename );
161 $extension = strtolower( pathinfo( $size_filename, PATHINFO_EXTENSION ) );
162 if ( $extension === 'jpeg' ) {
163 $extension = 'jpg';
164 }
165 if ( $extension === '' || !in_array( $extension, $allowed_extensions, true ) ) {
166 return new \WP_Error('squeeze_upload_image_thumbs_failed', '' . esc_html__( 'Invalid image format', 'squeeze' ));
167 }
168 if ( $size_name === 'full' ) {
169 $size_name = 'scaled';
170 unset($sizes['full']);
171 }
172 $original_size = ( file_exists( $upload_path . $size_filename ) ? wp_filesize( $upload_path . $size_filename ) : 0 );
173 $compressed_size = strlen( $size_decoded );
174 $sizes[$size_name]['original_size'] = $original_size;
175 $sizes[$size_name]['compressed_size'] = $compressed_size;
176 // Missing or zero-byte original on disk (e.g. offloaded thumb) — upload compressed file, skip comparison.
177 if ( $original_size <= 0 ) {
178 $upload_size_file = $wp_filesystem->put_contents( $upload_path . $size_filename, $size_decoded );
179 if ( !$upload_size_file ) {
180 return new \WP_Error('squeeze_upload_image_thumbs_failed', '' . esc_html__( 'Upload image failed', 'squeeze' ) . ': <br>upload_path: ' . $upload_path . '<br>filename: ' . $size_filename);
181 }
182 continue;
183 }
184 // if compressed size is larger than original, skip uploading and keep original
185 if ( $compressed_size > $original_size ) {
186 $sizes[$size_name]['compressed_size'] = $original_size;
187 continue;
188 }
189 $upload_size_file = $wp_filesystem->put_contents( $upload_path . $size_filename, $size_decoded );
190 if ( !$upload_size_file ) {
191 return new \WP_Error('squeeze_upload_image_thumbs_failed', '' . esc_html__( 'Upload image failed', 'squeeze' ) . ': <br>upload_path: ' . $upload_path . '<br>filename: ' . $size_filename);
192 } else {
193 $sizes[$size_name]['compressed_size'] = $compressed_size;
194 }
195 }
196 return $sizes;
197 }
198
199 public function upload_webp(
200 $upload_path,
201 $base64_webp,
202 $filename,
203 $is_file = false
204 ) {
205 if ( !$base64_webp ) {
206 return new \WP_Error('squeeze_upload_webp_failed', '' . esc_html__( 'No WebP data found', 'squeeze' ));
207 }
208 $upload_webp_path = $this->convert_image_path_to_webp_path( $upload_path );
209 if ( !file_exists( $upload_webp_path ) ) {
210 wp_mkdir_p( $upload_webp_path );
211 }
212 $decoded_webp = ( $is_file ? $base64_webp : $this->decode_base64_image( $base64_webp, 'webp' ) );
213 $filename_webp = $filename . '.webp';
214 $upload_file_webp = $this->upload_image(
215 $upload_webp_path,
216 $filename_webp,
217 $decoded_webp,
218 $is_file
219 );
220 return $upload_file_webp;
221 }
222
223 public function upload_webp_thumbs( $upload_path, $sizes_webp ) {
224 if ( !is_array( $sizes_webp ) || empty( $sizes_webp ) ) {
225 return new \WP_Error('squeeze_upload_webp_thumbs_failed', '' . esc_html__( 'No WebP data found', 'squeeze' ));
226 }
227 $upload_webp_path = $this->convert_image_path_to_webp_path( $upload_path );
228 if ( !file_exists( $upload_webp_path ) ) {
229 wp_mkdir_p( $upload_webp_path );
230 }
231 $allowed_extensions = array_keys( self::ALLOWED_IMAGE_FORMATS );
232 foreach ( $sizes_webp as $size_name => $size_data ) {
233 if ( $size_name === 'original' ) {
234 continue;
235 }
236 $size_base64 = sanitize_text_field( $size_data['base64'] );
237 $size_decoded = $this->decode_base64_image( $size_base64, 'webp' );
238 $size_filename = sanitize_file_name( basename( sanitize_url( $size_data['url'] ) ) );
239 $extension = strtolower( pathinfo( $size_filename, PATHINFO_EXTENSION ) );
240 if ( $extension === 'jpeg' ) {
241 $extension = 'jpg';
242 }
243 if ( $extension === '' || !in_array( $extension, $allowed_extensions, true ) ) {
244 return new \WP_Error('squeeze_upload_webp_thumbs_failed', '' . esc_html__( 'Invalid image format', 'squeeze' ));
245 }
246 $size_filename = $size_filename . '.webp';
247 $upload_size_file = $this->upload_image( $upload_webp_path, $size_filename, $size_decoded );
248 }
249 return $sizes_webp;
250 }
251
252 /**
253 * Pick a Direct WebP filename, reclaiming an unreferenced orphan instead of minting photo-1.webp.
254 *
255 * @param string $dirname Absolute directory (uploads year/month folder).
256 * @param string $desired_filename e.g. photo.webp
257 * @param int $exclude_attach_id Attachment being converted; its own _wp_attached_file is ignored.
258 * @return string Filename to write (desired name, or wp_unique_filename result if the path is owned).
259 */
260 public function get_direct_webp_filename( $dirname, $desired_filename, $exclude_attach_id = 0 ) {
261 $desired_filename = sanitize_file_name( $desired_filename );
262 $desired_path = trailingslashit( $dirname ) . $desired_filename;
263 if ( !file_exists( $desired_path ) ) {
264 return $desired_filename;
265 }
266 if ( !$this->is_uploads_path_owned_by_other_attachment( $desired_path, $exclude_attach_id ) ) {
267 wp_delete_file( $desired_path );
268 return $desired_filename;
269 }
270 return wp_unique_filename( $dirname, $desired_filename );
271 }
272
273 /**
274 * True when another attachment's _wp_attached_file points at this uploads path.
275 *
276 * @param string $absolute_path Absolute filesystem path under uploads.
277 * @param int $exclude_attach_id Attachment ID to ignore.
278 * @return bool
279 */
280 public function is_uploads_path_owned_by_other_attachment( $absolute_path, $exclude_attach_id = 0 ) {
281 $uploads = wp_upload_dir();
282 if ( empty( $uploads['basedir'] ) ) {
283 return true;
284 }
285 $basedir = wp_normalize_path( $uploads['basedir'] );
286 $path = wp_normalize_path( $absolute_path );
287 if ( strpos( $path, $basedir ) !== 0 ) {
288 return true;
289 }
290 $relative = ltrim( substr( $path, strlen( $basedir ) ), '/' );
291 global $wpdb;
292 $post_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1", $relative ) );
293 return $post_id > 0 && $post_id !== (int) $exclude_attach_id;
294 }
295
296 public function convert_image_path_to_webp_path( $image_path ) {
297 //$webp_path = preg_replace('/wp-content[\/\\\\]/', 'wp-content/squeeze-webp/', $image_path, 1);
298 //return str_replace(['/', '\\'], '/', $webp_path);
299 // Grab your actual content folder name (e.g. "wp-content" or custom)
300 $content_folder = basename( WP_CONTENT_DIR );
301 // Build a regex to match that folder plus a slash or backslash
302 $pattern = sprintf( '#%s[\\/\\\\]#', preg_quote( $content_folder, '#' ) );
303 // Replace with "{folder}/squeeze-webp/"
304 $replacement = $content_folder . '/squeeze-webp/';
305 // Do the one-time replacement…
306 $webp_path = preg_replace(
307 $pattern,
308 $replacement,
309 $image_path,
310 1
311 );
312 // Normalize backslashes to forward slashes and return
313 return str_replace( '\\', '/', $webp_path );
314 }
315
316 /**
317 * Map a full media URL (scheme or protocol-relative) to a path under ABSPATH.
318 * Strips the configured CDN base URL first when set, then home_url(), matching premium behavior.
319 *
320 * @param string $absolute_url Full URL, e.g. https://cdn.example.com/wp-content/uploads/...
321 * @return string Absolute filesystem path, or empty string if the URL does not map to this site.
322 */
323 public function resolve_media_url_to_abspath( $absolute_url ) {
324 if ( !is_string( $absolute_url ) || $absolute_url === '' ) {
325 return '';
326 }
327 if ( strpos( $absolute_url, '//' ) === 0 ) {
328 $absolute_url = (( is_ssl() ? 'https:' : 'http:' )) . $absolute_url;
329 }
330 $cdn = trim( (string) $this->get_option( 'cdn_url' ) );
331 /**
332 * Filter additional base URLs that should be stripped when resolving a media URL
333 * to an absolute filesystem path.
334 *
335 * Integrations that serve images from an external domain (e.g. WP Offload Media
336 * serving from an S3/GCS CDN) can add their provider URL(s) here so that Squeeze
337 * can correctly map CDN URLs back to local paths.
338 *
339 * @since 1.8.0
340 * @param string[] $urls Existing extra base URLs (empty by default).
341 */
342 $additional_bases = (array) apply_filters( 'squeeze_additional_base_urls', array() );
343 $bases = array_values( array_filter( array_unique( array_merge( ( $cdn !== '' ? array($cdn) : array() ), $additional_bases, array(home_url()) ) ) ) );
344 $rel = str_replace( $bases, '', $absolute_url );
345 $rel = ltrim( str_replace( '\\', '/', $rel ), '/' );
346 if ( $rel === '' || strpos( $rel, '..' ) !== false ) {
347 return '';
348 }
349 $content_folder = basename( WP_CONTENT_DIR );
350 if ( stripos( $rel, $content_folder . '/' ) !== 0 && stripos( $rel, $content_folder ) !== 0 ) {
351 return '';
352 }
353 return ABSPATH . $rel;
354 }
355
356 public function can_restore( $attach_id ) {
357 $original_img_path = wp_get_original_image_path( (int) $attach_id );
358 $backup_img_path = preg_replace( "/(\\.(?!.*\\.))/", '.bak.', $original_img_path );
359 $can_restore = file_exists( $backup_img_path );
360 return $can_restore;
361 }
362
363 public function restore_attachment( $attach_id, $is_bulk = false ) {
364 if ( !function_exists( 'WP_Filesystem' ) ) {
365 require_once ABSPATH . 'wp-admin/includes/file.php';
366 }
367 global $wp_filesystem;
368 // Initialize the filesystem (this populates $wp_filesystem)
369 WP_Filesystem();
370 if ( !$wp_filesystem || !method_exists( $wp_filesystem, 'copy' ) ) {
371 if ( $is_bulk ) {
372 wp_die( 'Filesystem API is not available or failed to initialize.' );
373 } else {
374 return new \WP_Error('squeeze_filesystem_api_error', 'Filesystem API is not available or failed to initialize.');
375 }
376 }
377 $original_img_path = wp_get_original_image_path( $attach_id );
378 $backup_img_path = preg_replace( "/(\\.(?!.*\\.))/", '.bak.', $original_img_path );
379 if ( !file_exists( $backup_img_path ) ) {
380 $error_message = '' . esc_html__( 'Backup image not found', 'squeeze' );
381 if ( $is_bulk ) {
382 wp_die( esc_html( $error_message ) );
383 } else {
384 return new \WP_Error('squeeze_restore_attachment_failed', $error_message);
385 }
386 return false;
387 }
388 $backup_img = $wp_filesystem->copy( $backup_img_path, $original_img_path, true );
389 if ( !$backup_img ) {
390 $error_message = '' . esc_html__( 'Restore original image failed', 'squeeze' );
391 if ( $is_bulk ) {
392 wp_die( esc_html( $error_message ) );
393 } else {
394 return new \WP_Error('squeeze_restore_attachment_failed', $error_message);
395 }
396 return false;
397 }
398 $attachment_data = wp_create_image_subsizes( $original_img_path, $attach_id );
399 if ( !delete_post_meta( $attach_id, "squeeze_is_compressed" ) ) {
400 return false;
401 }
402 wp_delete_file( $backup_img_path );
403 $this->delete_webp_images( $original_img_path, $attachment_data );
404 $uncompressed_images = $this->get_stats_option( 'uncompressed_images' );
405 update_option( 'squeeze_stats', array(
406 'uncompressed_images' => ++$uncompressed_images,
407 ) );
408 return true;
409 }
410
411 public function delete_webp_images( $original_img_path, $attachment_data ) {
412 $result = false;
413 if ( !is_array( $attachment_data ) || empty( $attachment_data ) ) {
414 return $result;
415 }
416 $original_filename = pathinfo( $original_img_path, PATHINFO_BASENAME );
417 $webp_path = $this->convert_image_path_to_webp_path( $original_img_path );
418 $result = wp_delete_file( $webp_path . '.webp' );
419 foreach ( $attachment_data['sizes'] as $size_data ) {
420 $webp_thumb_path = str_replace( $original_filename, $size_data['file'] . '.webp', $original_img_path );
421 $result = wp_delete_file( $this->convert_image_path_to_webp_path( $webp_thumb_path ) );
422 }
423 $webp_scaled_filename = pathinfo( $attachment_data['file'], PATHINFO_BASENAME );
424 $webp_scaled_path = $this->convert_image_path_to_webp_path( str_replace( $original_filename, $webp_scaled_filename . '.webp', $original_img_path ) );
425 if ( file_exists( $webp_scaled_path ) ) {
426 $result = wp_delete_file( $webp_scaled_path );
427 }
428 return $result;
429 }
430
431 public function get_stats_option( $option ) {
432 $stats = get_option( 'squeeze_stats' );
433 $option_value = ( isset( $stats[$option] ) ? $stats[$option] : 0 );
434 return $option_value;
435 }
436
437 public function get_option( $option ) {
438 // Cache squeeze_options array per request to avoid repeated database queries
439 // This prevents hundreds of get_option('squeeze_options') calls during bulk operations
440 if ( self::$cached_squeeze_options === null ) {
441 self::$cached_squeeze_options = get_option( 'squeeze_options' );
442 }
443 $options = self::$cached_squeeze_options;
444 $option_value = ( isset( $options[$option] ) ? $options[$option] : $this->get_default_value( $option ) );
445 return $option_value;
446 }
447
448 public function set_options( $options ) {
449 $default_options = $this->get_default_value( 'all', true );
450 $options = wp_parse_args( $options, $default_options );
451 // Strip any keys not present in the known defaults — prevents option-injection attacks.
452 $options = array_intersect_key( $options, $default_options );
453 // Constrain compress_formats to the hardcoded constant so callers cannot introduce
454 // arbitrary extensions (e.g. 'php') that would later be accepted as upload targets.
455 if ( isset( $options['compress_formats'] ) && is_array( $options['compress_formats'] ) ) {
456 $options['compress_formats'] = array_intersect_key( $options['compress_formats'], self::ALLOWED_IMAGE_FORMATS );
457 if ( empty( $options['compress_formats'] ) ) {
458 $options['compress_formats'] = self::ALLOWED_IMAGE_FORMATS;
459 }
460 }
461 $result = update_option( 'squeeze_options', $options );
462 // Clear the cache when options are updated
463 if ( $result ) {
464 self::$cached_squeeze_options = $options;
465 // New settings may produce smaller output — let all previously-failed images be retried
466 $this->clear_compression_failed_meta();
467 }
468 return $result;
469 }
470
471 private function clear_compression_failed_meta() {
472 global $wpdb;
473 $wpdb->delete( $wpdb->postmeta, array(
474 'meta_key' => 'squeeze_compression_failed',
475 ) );
476 }
477
478 /**
479 * Opt-in cleanup of plugin metadata on uninstall.
480 * Static so it can be used with register_uninstall_hook (free) and Freemius after_uninstall (premium).
481 * Does not delete image files (originals, backups, or WebP copies).
482 */
483 public static function uninstall_cleanup() {
484 $options = \get_option( 'squeeze_options', array() );
485 if ( empty( $options['clear_data_on_uninstall'] ) ) {
486 return;
487 }
488 \delete_option( 'squeeze_options' );
489 \delete_option( 'squeeze_stats' );
490 \delete_transient( 'squeeze_bulk_path' );
491 self::$cached_squeeze_options = null;
492 global $wpdb;
493 $wpdb->delete( $wpdb->postmeta, array(
494 'meta_key' => 'squeeze_is_compressed',
495 ) );
496 $wpdb->delete( $wpdb->postmeta, array(
497 'meta_key' => 'squeeze_compression_failed',
498 ) );
499 }
500
501 public function get_comparison_table( $sizes ) {
502 if ( !is_array( $sizes ) || empty( $sizes ) ) {
503 return '';
504 }
505 //$table = print_r($sizes, true);
506 $table = '<div class="squeeze-comparison-table">';
507 $table .= '<table class="wp-list-table widefat striped">';
508 $table .= '<thead><tr><th>' . esc_html__( 'Size Name', 'squeeze' ) . '</th><th>' . esc_html__( 'Original Size', 'squeeze' ) . '</th><th>' . esc_html__( 'Squeezed Size', 'squeeze' ) . '</th><th>' . esc_html__( 'Savings', 'squeeze' ) . ' (%)</th></tr></thead>';
509 $table .= '<tbody>';
510 foreach ( $sizes as $size_name => $size_data ) {
511 /*if (!isset($size_data['url'])) {
512 continue;
513 }*/
514 //$size_filename = basename(sanitize_url($size_data['url']));
515 $original_size = $size_data['original_size'];
516 $compressed_size = $size_data['compressed_size'];
517 if ( $original_size <= 0 ) {
518 $savings_percent = esc_html__( 'N/A', 'squeeze' );
519 $savings_class = '';
520 } else {
521 $savings = $original_size - $compressed_size;
522 $savings_percent = round( $savings / $original_size * 100, 2 ) . '%';
523 $savings_class = ( $savings > 0 ? 'squeeze-savings-positive' : 'squeeze-savings-negative' );
524 }
525 $table .= '<tr>';
526 $table .= '<td><strong>' . $size_name . '</strong></td>';
527 $table .= '<td>' . (( $original_size > 0 ? size_format( $original_size, 0 ) : esc_html__( 'N/A', 'squeeze' ) )) . '</td>';
528 $table .= '<td>' . size_format( $compressed_size, 0 ) . '</td>';
529 $table .= '<td><span class="squeeze-savings-label ' . $savings_class . '">' . $savings_percent . '</span></td>';
530 $table .= '</tr>';
531 }
532 $table .= '</tbody></table></div>';
533 return $table;
534 }
535
536 public function is_webp_replace_urls() {
537 $is_auto_webp = $this->get_option( 'auto_webp' );
538 $is_webp_replace_urls = $this->get_option( 'webp_replace_urls' );
539 if ( !$is_auto_webp || !$is_webp_replace_urls ) {
540 return false;
541 }
542 return true;
543 }
544
545 public function get_image_formats( $return_mimes = false, $custom_formats = [] ) {
546 $allowed_image_formats = ( empty( $custom_formats ) ? $this->get_option( 'compress_formats' ) : $custom_formats );
547 // Intersect with the hardcoded constant so a polluted option can never introduce
548 // non-image extensions (e.g. 'php') into the upload allowlist.
549 if ( is_array( $allowed_image_formats ) ) {
550 $allowed_image_formats = array_intersect_key( $allowed_image_formats, self::ALLOWED_IMAGE_FORMATS );
551 }
552 if ( empty( $allowed_image_formats ) ) {
553 $allowed_image_formats = self::ALLOWED_IMAGE_FORMATS;
554 }
555 $allowed_image_formats = array_keys( $allowed_image_formats );
556 // make values the same as keys
557 $allowed_image_formats = array_combine( $allowed_image_formats, $allowed_image_formats );
558 if ( $return_mimes ) {
559 $allowed_image_formats = array_map( function ( $format ) {
560 $format = ( $format === 'jpg' ? 'jpeg' : $format );
561 // handle jpg/jpeg mime type
562 return 'image/' . $format;
563 }, $allowed_image_formats );
564 }
565 return $allowed_image_formats;
566 }
567
568 public function get_total_images_count() {
569 $total_images = $this->get_stats_option( 'total_images' );
570 if ( $total_images > 0 ) {
571 return $total_images;
572 }
573 $query_all = new \WP_Query(array(
574 'post_type' => 'attachment',
575 'post_status' => 'inherit',
576 'post_mime_type' => $this->get_image_formats( true ),
577 'posts_per_page' => -1,
578 'fields' => 'ids',
579 ));
580 $total_images = $query_all->found_posts;
581 $stats['total_images'] = $total_images;
582 update_option( 'squeeze_stats', $stats );
583 return $total_images;
584 }
585
586 /**
587 * Meta query for the Media Library "Non Squeezed Images" filter (list and grid).
588 *
589 * @return array<string, mixed>
590 */
591 public function get_non_squeezed_media_meta_query() {
592 return array(
593 'relation' => 'AND',
594 array(
595 'relation' => 'OR',
596 array(
597 'key' => 'squeeze_is_compressed',
598 'compare' => '!=',
599 'value' => '1',
600 ),
601 array(
602 'key' => 'squeeze_is_compressed',
603 'compare' => 'NOT EXISTS',
604 ),
605 ),
606 array(
607 'relation' => 'OR',
608 array(
609 'key' => 'squeeze_compression_failed',
610 'compare' => 'NOT EXISTS',
611 ),
612 array(
613 'key' => 'squeeze_compression_failed',
614 'value' => 'larger_than_original',
615 'compare' => '!=',
616 ),
617 ),
618 );
619 }
620
621 /**
622 * Meta query shared by bulk uncompressed image queries.
623 *
624 * @return array<string, mixed>
625 */
626 public function get_uncompressed_meta_query() {
627 return array(
628 'relation' => 'AND',
629 array(
630 'relation' => 'OR',
631 array(
632 'key' => 'squeeze_is_compressed',
633 'compare' => 'NOT EXISTS',
634 ),
635 array(
636 'key' => 'squeeze_is_compressed',
637 'compare' => '!=',
638 'value' => '1',
639 ),
640 ),
641 array(
642 'key' => 'squeeze_compression_failed',
643 'compare' => 'NOT EXISTS',
644 ),
645 );
646 }
647
648 /**
649 * @return array<string, mixed>
650 */
651 private function get_uncompressed_images_query_args( $last_id = 0 ) {
652 return array(
653 'post_type' => 'attachment',
654 'post_status' => 'inherit',
655 'post_mime_type' => $this->get_image_formats( true ),
656 'posts_per_page' => self::$MEDIA_PER_PAGE,
657 'fields' => 'ids',
658 'orderby' => 'ID',
659 'order' => 'DESC',
660 'meta_query' => $this->get_uncompressed_meta_query(),
661 'squeeze_last_id' => $last_id,
662 );
663 }
664
665 public function get_uncompressed_images_count() {
666 $uncompressed_images = $this->get_stats_option( 'uncompressed_images' );
667 if ( $uncompressed_images > 0 ) {
668 return $uncompressed_images;
669 }
670 $args = $this->get_uncompressed_images_query_args();
671 $args['posts_per_page'] = -1;
672 $query_uncompressed = new \WP_Query($args);
673 $uncompressed_images = count( $this->filter_excluded_attachment_ids( $query_uncompressed->posts ) );
674 $stats['uncompressed_images'] = $uncompressed_images;
675 update_option( 'squeeze_stats', $stats );
676 return $uncompressed_images;
677 }
678
679 public function get_images_from_last_id( $where, $wp_query ) {
680 if ( $wp_query->get( 'squeeze_last_id' ) !== null && $wp_query->get( 'squeeze_last_id' ) > 0 ) {
681 global $wpdb;
682 $last = intval( $wp_query->get( 'squeeze_last_id' ) );
683 $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $last );
684 }
685 return $where;
686 }
687
688 public function get_uncompressed_images( $last_id = 0 ) {
689 $excluded_images = $this->get_excluded_images();
690 $results = array();
691 $current_last_id = $last_id;
692 // Cap how many batches we scan while backfilling after exclusions (~20 default pages of candidates).
693 $max_iterations = max( 2, (int) ceil( 1000 / self::$MEDIA_PER_PAGE ) );
694 while ( count( $results ) < self::$MEDIA_PER_PAGE && $max_iterations-- > 0 ) {
695 $query = new \WP_Query($this->get_uncompressed_images_query_args( $current_last_id ));
696 $posts = $query->posts;
697 if ( empty( $posts ) ) {
698 break;
699 }
700 $batch = ( empty( $excluded_images ) ? $posts : $this->filter_excluded_attachment_ids( $posts, $excluded_images ) );
701 $results = array_merge( $results, $batch );
702 if ( count( $posts ) < self::$MEDIA_PER_PAGE ) {
703 break;
704 }
705 $current_last_id = (int) end( $posts );
706 }
707 return array_slice( array_values( array_unique( array_map( 'intval', $results ) ) ), 0, self::$MEDIA_PER_PAGE );
708 }
709
710 public function get_total_images( $paged = 1 ) {
711 $args = array(
712 'post_type' => 'attachment',
713 'post_status' => 'inherit',
714 'post_mime_type' => $this->get_image_formats( true ),
715 'posts_per_page' => self::$MEDIA_PER_PAGE,
716 'paged' => $paged,
717 'fields' => 'ids',
718 );
719 $query_all = new \WP_Query($args);
720 return $query_all->posts;
721 }
722
723 public function get_hint( $hint, $class = 'squeeze-hint' ) {
724 return '<span class="' . esc_attr( $class ) . '">' . esc_html( $hint ) . '</span>';
725 }
726
727 /**
728 * Parsed list of exclusion patterns (one per line in settings). Cached per request.
729 *
730 * @return string[]
731 */
732 public function get_excluded_images() {
733 if ( null !== self::$cached_excluded_images ) {
734 return self::$cached_excluded_images;
735 }
736 $raw = $this->get_option( 'excluded_images' );
737 if ( !is_string( $raw ) || $raw === '' ) {
738 self::$cached_excluded_images = array();
739 return self::$cached_excluded_images;
740 }
741 $lines = explode( "\n", $raw );
742 $lines = array_filter( array_map( 'trim', $lines ), static function ( $line ) {
743 return $line !== '';
744 } );
745 self::$cached_excluded_images = array_values( $lines );
746 return self::$cached_excluded_images;
747 }
748
749 /**
750 * Whether a URL or path matches any exclusion pattern (substring match, case-insensitive).
751 *
752 * @param string|null $image_path URL or path fragment.
753 * @param string[]|null $excluded_images Patterns from get_excluded_images(); null loads from options.
754 * @return array{is_excluded: true, exclude_reason: string}|false
755 */
756 public function is_excluded_image( $image_path, $excluded_images = null ) {
757 if ( !$image_path ) {
758 return false;
759 }
760 if ( null === $excluded_images ) {
761 $excluded_images = $this->get_excluded_images();
762 }
763 if ( empty( $excluded_images ) ) {
764 return false;
765 }
766 foreach ( $excluded_images as $excluded_image ) {
767 $excluded_image = trim( $excluded_image );
768 if ( $excluded_image === '' ) {
769 continue;
770 }
771 if ( stripos( $image_path, $excluded_image ) !== false ) {
772 return array(
773 'is_excluded' => true,
774 'exclude_reason' => $excluded_image,
775 );
776 }
777 }
778 return false;
779 }
780
781 /**
782 * Whether a media-library attachment matches excluded-images settings.
783 *
784 * @param int $attachment_id Attachment post ID.
785 * @param string[]|null $excluded_images Patterns from get_excluded_images(); null loads from options.
786 */
787 public function is_attachment_excluded( $attachment_id, $excluded_images = null ) {
788 if ( !$attachment_id || 'attachment' !== get_post_type( $attachment_id ) ) {
789 return false;
790 }
791 if ( null === $excluded_images ) {
792 $excluded_images = $this->get_excluded_images();
793 }
794 if ( empty( $excluded_images ) ) {
795 return false;
796 }
797 $candidates = array_filter( array(wp_get_attachment_url( $attachment_id ), wp_get_original_image_url( $attachment_id ), get_attached_file( $attachment_id )) );
798 $full_image = wp_get_attachment_image_src( $attachment_id, 'full' );
799 if ( !empty( $full_image[0] ) ) {
800 $candidates[] = $full_image[0];
801 }
802 foreach ( array_unique( $candidates ) as $candidate ) {
803 if ( $this->is_excluded_image( $candidate, $excluded_images ) ) {
804 return true;
805 }
806 }
807 return false;
808 }
809
810 /**
811 * @param int[] $attachment_ids
812 * @param string[]|null $excluded_images
813 * @return int[]
814 */
815 public function filter_excluded_attachment_ids( array $attachment_ids, $excluded_images = null ) {
816 if ( empty( $attachment_ids ) ) {
817 return $attachment_ids;
818 }
819 if ( null === $excluded_images ) {
820 $excluded_images = $this->get_excluded_images();
821 }
822 if ( empty( $excluded_images ) ) {
823 return $attachment_ids;
824 }
825 return array_values( array_filter( $attachment_ids, function ( $attachment_id ) use($excluded_images) {
826 return !$this->is_attachment_excluded( (int) $attachment_id, $excluded_images );
827 } ) );
828 }
829
830 public function clear_excluded_images_cache() {
831 self::$cached_excluded_images = null;
832 }
833
834 public function clear_options_cache() {
835 self::$cached_squeeze_options = null;
836 }
837
838 public function invalidate_uncompressed_stats_cache() {
839 $stats = get_option( 'squeeze_stats', array() );
840 if ( isset( $stats['uncompressed_images'] ) ) {
841 unset($stats['uncompressed_images']);
842 update_option( 'squeeze_stats', $stats );
843 }
844 }
845
846 public function get_default_value( $option, $all = false ) {
847 $options_defaults = apply_filters( 'squeeze_options_default', array(
848 'jpeg_quality' => 80,
849 'jpeg_baseline' => false,
850 'jpeg_progressive' => true,
851 'jpeg_optimize_coding' => true,
852 'jpeg_smoothing' => 0,
853 'jpeg_color_space' => 3,
854 'jpeg_quant_table' => 3,
855 'jpeg_trellis_multipass' => false,
856 'jpeg_trellis_opt_zero' => false,
857 'jpeg_trellis_opt_table' => false,
858 'jpeg_trellis_loops' => 1,
859 'jpeg_auto_subsample' => true,
860 'jpeg_chroma_subsample' => 2,
861 'jpeg_separate_chroma_quality' => false,
862 'jpeg_chroma_quality' => 75,
863 'png_level' => 2,
864 'png_interlace' => false,
865 'png_quality' => 0.7,
866 'webp_method' => 4,
867 'webp_quality' => 80,
868 'webp_lossless' => false,
869 'webp_near_lossless' => 100,
870 'avif_cqLevel' => 70,
871 'auto_compress' => true,
872 'auto_webp' => false,
873 'webp_replace_urls' => false,
874 'direct_webp' => true,
875 'cdn_url' => '',
876 'backup_original' => true,
877 'clear_data_on_uninstall' => false,
878 'compress_formats' => self::ALLOWED_IMAGE_FORMATS,
879 'compress_thumbs' => array(
880 'large' => 'on',
881 'full' => 'on',
882 ),
883 'max_width' => '',
884 'max_height' => '',
885 'excluded_images' => '',
886 'timeout' => 60,
887 'restore_defaults' => false,
888 ) );
889 if ( $all ) {
890 return $options_defaults;
891 }
892 return ( in_array( $option, array_keys( $options_defaults ) ) ? $options_defaults[$option] : false );
893 }
894
895 public function get_thumb_sizes() {
896 $sizes = wp_get_registered_image_subsizes();
897 if ( !empty( $sizes ) && is_array( $sizes ) ) {
898 // Add the scaled image size option if it fits the image dimensions
899 $big_image_size_threshold = apply_filters( 'big_image_size_threshold', 2560 );
900 if ( $big_image_size_threshold ) {
901 $sizes['full'] = array(
902 'width' => $big_image_size_threshold,
903 'height' => $big_image_size_threshold,
904 'crop' => false,
905 );
906 }
907 }
908 return $sizes;
909 }
910
911 public function is_rest_enabled() {
912 // Check if REST API is enabled
913 if ( !function_exists( 'rest_get_server' ) || !rest_get_server() ) {
914 return false;
915 }
916 return (bool) apply_filters( 'rest_enabled', true );
917 }
918
919 public function apache_get_modules() {
920 if ( !function_exists( 'apache_get_modules' ) || !in_array( 'mod_rewrite', apache_get_modules() ) ) {
921 return false;
922 }
923 return apache_get_modules();
924 }
925
926 /**
927 * Convert a filesystem directory under ABSPATH to site-root-relative form (/wp-content/foo/).
928 *
929 * @param string $filesystem_dir Absolute directory path.
930 * @return string Leading slash, forward slashes, trailing slash (or '/' for site root).
931 */
932 public function bulk_directory_uri_from_filesystem( $filesystem_dir ) {
933 $abs_base = wp_normalize_path( ABSPATH );
934 $full = wp_normalize_path( rtrim( (string) $filesystem_dir, '/\\' ) );
935 if ( strpos( $full, $abs_base ) !== 0 ) {
936 $slash = preg_replace( '#/+#', '/', str_replace( '\\', '/', str_replace( ABSPATH, '/', (string) $filesystem_dir ) ) );
937 if ( '/' === $slash || '' === $slash ) {
938 return '/';
939 }
940 if ( '/' !== substr( $slash, 0, 1 ) ) {
941 $slash = '/' . ltrim( $slash, '/' );
942 }
943 return ( substr( $slash, -1 ) === '/' ? $slash : $slash . '/' );
944 }
945 $rel = substr( $full, strlen( $abs_base ) );
946 $rel = trim( str_replace( '\\', '/', $rel ), '/' );
947 return ( '' === $rel ? '/' : '/' . $rel . '/' );
948 }
949
950 /**
951 * Turn browse API parentDir into a path relative to WP_CONTENT_DIR (uploads, themes/foo, or '').
952 *
953 * @param string $parent_directory Raw POST value (e.g. /wp-content/uploads/ or wp-content/uploads).
954 * @return string No leading/trailing slashes.
955 */
956 public function bulk_parent_relative_to_content_dir( $parent_directory ) {
957 $parent_directory = preg_replace( '#/+#', '/', str_replace( '\\', '/', (string) $parent_directory ) );
958 $parent_directory = trim( $parent_directory, '/' );
959 if ( '' === $parent_directory ) {
960 return '';
961 }
962 if ( 'wp-content' === $parent_directory ) {
963 return '';
964 }
965 if ( 0 === strpos( $parent_directory, 'wp-content/' ) ) {
966 return trim( substr( $parent_directory, strlen( 'wp-content/' ) ), '/' );
967 }
968 return $parent_directory;
969 }
970
971 /**
972 * Normalize directory paths for bulk UI and JSON: never show filesystem absolute paths.
973 *
974 * @param string $path Raw path from transient, manual entry, etc.
975 * @return string Site-relative path like /wp-content/uploads/.
976 */
977 public function normalize_bulk_directory_storage_path( $path ) {
978 $path = trim( (string) $path );
979 if ( '' === $path ) {
980 return '/';
981 }
982 if ( preg_match( '#^https?://#i', $path ) ) {
983 $parsed = wp_parse_url( $path );
984 $path = ( isset( $parsed['path'] ) ? $parsed['path'] : '/' );
985 $site = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
986 if ( is_string( $site ) && strlen( $site ) > 1 ) {
987 $site = untrailingslashit( $site );
988 if ( $site && strpos( $path, $site . '/' ) === 0 ) {
989 $path = substr( $path, strlen( $site ) );
990 }
991 }
992 }
993 $clean = preg_replace( '#/+#', '/', str_replace( '\\', '/', $path ) );
994 $norm = wp_normalize_path( $clean );
995 $base = wp_normalize_path( ABSPATH );
996 if ( strlen( $norm ) >= 2 && ctype_alpha( $norm[0] ) && ':' === $norm[1] || strpos( $norm, $base ) === 0 ) {
997 return $this->bulk_directory_uri_from_filesystem( $clean );
998 }
999 if ( '/' !== substr( $clean, 0, 1 ) ) {
1000 $clean = '/' . ltrim( $clean, '/' );
1001 }
1002 if ( '/' !== substr( $clean, -1 ) ) {
1003 $clean .= '/';
1004 }
1005 return $clean;
1006 }
1007
1008 }
1009