PluginProbe
Squeeze – Image Optimization & Compression, WEBP Conversion / trunk
Squeeze – Image Optimization & Compression, WEBP Conversion vtrunk
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 trunk, at inc/helpers.php

1,044 lines 47.0 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 * Existing files referenced via _wp_attached_file, metadata file, or original_image (big-image -scaled) are kept unique.
255 *
256 * @param string $dirname Absolute directory (uploads year/month folder).
257 * @param string $desired_filename e.g. photo.webp
258 * @param int $exclude_attach_id Attachment being converted; its own references are ignored.
259 * @return string Filename to write (desired name, or wp_unique_filename result if the path is owned).
260 */
261 public function get_direct_webp_filename( $dirname, $desired_filename, $exclude_attach_id = 0 ) {
262 $desired_filename = sanitize_file_name( $desired_filename );
263 $desired_path = trailingslashit( $dirname ) . $desired_filename;
264 if ( !file_exists( $desired_path ) ) {
265 return $desired_filename;
266 }
267 if ( !$this->is_uploads_path_owned_by_other_attachment( $desired_path, $exclude_attach_id ) ) {
268 wp_delete_file( $desired_path );
269 return $desired_filename;
270 }
271 return wp_unique_filename( $dirname, $desired_filename );
272 }
273
274 /**
275 * True when another attachment references this uploads path via _wp_attached_file,
276 * metadata file, or original_image (big-image -scaled twin).
277 *
278 * @param string $absolute_path Absolute filesystem path under uploads.
279 * @param int $exclude_attach_id Attachment ID to ignore.
280 * @return bool
281 */
282 public function is_uploads_path_owned_by_other_attachment( $absolute_path, $exclude_attach_id = 0 ) {
283 $uploads = wp_upload_dir();
284 if ( empty( $uploads['basedir'] ) ) {
285 return true;
286 }
287 $basedir = wp_normalize_path( $uploads['basedir'] );
288 $path = wp_normalize_path( $absolute_path );
289 if ( strpos( $path, $basedir ) !== 0 ) {
290 return true;
291 }
292 $relative = ltrim( substr( $path, strlen( $basedir ) ), '/' );
293 global $wpdb;
294 $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 ) );
295 if ( $post_id > 0 && $post_id !== (int) $exclude_attach_id ) {
296 return true;
297 }
298 // Big images: _wp_attached_file is …-scaled.webp while the full file lives as original_image.
299 $basename = wp_basename( $path );
300 $dir_rel = str_replace( '\\', '/', dirname( $relative ) );
301 if ( $dir_rel === '.' || $dir_rel === '' ) {
302 $like = $wpdb->esc_like( $basename );
303 $candidates = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta}\r\n\t\t\t\t\t WHERE meta_key = '_wp_attached_file'\r\n\t\t\t\t\t AND ( meta_value = %s OR meta_value LIKE %s )", $basename, '%/' . $like ) );
304 } else {
305 $candidates = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta}\r\n\t\t\t\t\t WHERE meta_key = '_wp_attached_file'\r\n\t\t\t\t\t AND meta_value LIKE %s", $wpdb->esc_like( $dir_rel . '/' ) . '%' ) );
306 }
307 foreach ( (array) $candidates as $candidate_id ) {
308 $candidate_id = (int) $candidate_id;
309 if ( $candidate_id <= 0 || $candidate_id === (int) $exclude_attach_id ) {
310 continue;
311 }
312 $meta = wp_get_attachment_metadata( $candidate_id );
313 if ( !is_array( $meta ) ) {
314 continue;
315 }
316 if ( !empty( $meta['original_image'] ) && $meta['original_image'] === $basename ) {
317 return true;
318 }
319 if ( !empty( $meta['file'] ) && wp_basename( $meta['file'] ) === $basename ) {
320 return true;
321 }
322 }
323 return false;
324 }
325
326 public function convert_image_path_to_webp_path( $image_path ) {
327 //$webp_path = preg_replace('/wp-content[\/\\\\]/', 'wp-content/squeeze-webp/', $image_path, 1);
328 //return str_replace(['/', '\\'], '/', $webp_path);
329 // Grab your actual content folder name (e.g. "wp-content" or custom)
330 $content_folder = basename( WP_CONTENT_DIR );
331 // Build a regex to match that folder plus a slash or backslash
332 $pattern = sprintf( '#%s[\\/\\\\]#', preg_quote( $content_folder, '#' ) );
333 // Replace with "{folder}/squeeze-webp/"
334 $replacement = $content_folder . '/squeeze-webp/';
335 // Do the one-time replacement…
336 $webp_path = preg_replace(
337 $pattern,
338 $replacement,
339 $image_path,
340 1
341 );
342 // Normalize backslashes to forward slashes and return
343 return str_replace( '\\', '/', $webp_path );
344 }
345
346 /**
347 * Map a full media URL (scheme or protocol-relative) to a path under ABSPATH.
348 * Strips the configured CDN base URL first when set, then home_url(), matching premium behavior.
349 *
350 * @param string $absolute_url Full URL, e.g. https://cdn.example.com/wp-content/uploads/...
351 * @return string Absolute filesystem path, or empty string if the URL does not map to this site.
352 */
353 public function resolve_media_url_to_abspath( $absolute_url ) {
354 if ( !is_string( $absolute_url ) || $absolute_url === '' ) {
355 return '';
356 }
357 if ( strpos( $absolute_url, '//' ) === 0 ) {
358 $absolute_url = (( is_ssl() ? 'https:' : 'http:' )) . $absolute_url;
359 }
360 $cdn = trim( (string) $this->get_option( 'cdn_url' ) );
361 /**
362 * Filter additional base URLs that should be stripped when resolving a media URL
363 * to an absolute filesystem path.
364 *
365 * Integrations that serve images from an external domain (e.g. WP Offload Media
366 * serving from an S3/GCS CDN) can add their provider URL(s) here so that Squeeze
367 * can correctly map CDN URLs back to local paths.
368 *
369 * @since 1.8.0
370 * @param string[] $urls Existing extra base URLs (empty by default).
371 */
372 $additional_bases = (array) apply_filters( 'squeeze_additional_base_urls', array() );
373 $bases = array_values( array_filter( array_unique( array_merge( ( $cdn !== '' ? array($cdn) : array() ), $additional_bases, array(home_url()) ) ) ) );
374 $rel = str_replace( $bases, '', $absolute_url );
375 $rel = ltrim( str_replace( '\\', '/', $rel ), '/' );
376 if ( $rel === '' || strpos( $rel, '..' ) !== false ) {
377 return '';
378 }
379 $content_folder = basename( WP_CONTENT_DIR );
380 if ( stripos( $rel, $content_folder . '/' ) !== 0 && stripos( $rel, $content_folder ) !== 0 ) {
381 return '';
382 }
383 return ABSPATH . $rel;
384 }
385
386 public function can_restore( $attach_id ) {
387 $original_img_path = wp_get_original_image_path( (int) $attach_id );
388 $backup_img_path = preg_replace( "/(\\.(?!.*\\.))/", '.bak.', $original_img_path );
389 $can_restore = file_exists( $backup_img_path );
390 return $can_restore;
391 }
392
393 public function restore_attachment( $attach_id, $is_bulk = false ) {
394 if ( !function_exists( 'WP_Filesystem' ) ) {
395 require_once ABSPATH . 'wp-admin/includes/file.php';
396 }
397 global $wp_filesystem;
398 // Initialize the filesystem (this populates $wp_filesystem)
399 WP_Filesystem();
400 if ( !$wp_filesystem || !method_exists( $wp_filesystem, 'copy' ) ) {
401 if ( $is_bulk ) {
402 wp_die( 'Filesystem API is not available or failed to initialize.' );
403 } else {
404 return new \WP_Error('squeeze_filesystem_api_error', 'Filesystem API is not available or failed to initialize.');
405 }
406 }
407 $original_img_path = wp_get_original_image_path( $attach_id );
408 $backup_img_path = preg_replace( "/(\\.(?!.*\\.))/", '.bak.', $original_img_path );
409 if ( !file_exists( $backup_img_path ) ) {
410 $error_message = '' . esc_html__( 'Backup image not found', 'squeeze' );
411 if ( $is_bulk ) {
412 wp_die( esc_html( $error_message ) );
413 } else {
414 return new \WP_Error('squeeze_restore_attachment_failed', $error_message);
415 }
416 return false;
417 }
418 $backup_img = $wp_filesystem->copy( $backup_img_path, $original_img_path, true );
419 if ( !$backup_img ) {
420 $error_message = '' . esc_html__( 'Restore original image failed', 'squeeze' );
421 if ( $is_bulk ) {
422 wp_die( esc_html( $error_message ) );
423 } else {
424 return new \WP_Error('squeeze_restore_attachment_failed', $error_message);
425 }
426 return false;
427 }
428 $attachment_data = wp_create_image_subsizes( $original_img_path, $attach_id );
429 if ( !delete_post_meta( $attach_id, "squeeze_is_compressed" ) ) {
430 return false;
431 }
432 wp_delete_file( $backup_img_path );
433 $this->delete_webp_images( $original_img_path, $attachment_data );
434 $uncompressed_images = $this->get_stats_option( 'uncompressed_images' );
435 update_option( 'squeeze_stats', array(
436 'uncompressed_images' => ++$uncompressed_images,
437 ) );
438 return true;
439 }
440
441 public function delete_webp_images( $original_img_path, $attachment_data ) {
442 $result = false;
443 if ( !is_array( $attachment_data ) || empty( $attachment_data ) ) {
444 return $result;
445 }
446 $original_filename = pathinfo( $original_img_path, PATHINFO_BASENAME );
447 $webp_path = $this->convert_image_path_to_webp_path( $original_img_path );
448 $result = wp_delete_file( $webp_path . '.webp' );
449 foreach ( $attachment_data['sizes'] as $size_data ) {
450 $webp_thumb_path = str_replace( $original_filename, $size_data['file'] . '.webp', $original_img_path );
451 $result = wp_delete_file( $this->convert_image_path_to_webp_path( $webp_thumb_path ) );
452 }
453 $webp_scaled_filename = pathinfo( $attachment_data['file'], PATHINFO_BASENAME );
454 $webp_scaled_path = $this->convert_image_path_to_webp_path( str_replace( $original_filename, $webp_scaled_filename . '.webp', $original_img_path ) );
455 if ( file_exists( $webp_scaled_path ) ) {
456 $result = wp_delete_file( $webp_scaled_path );
457 }
458 return $result;
459 }
460
461 public function get_stats_option( $option ) {
462 $stats = get_option( 'squeeze_stats' );
463 $option_value = ( isset( $stats[$option] ) ? $stats[$option] : 0 );
464 return $option_value;
465 }
466
467 public function get_option( $option ) {
468 // Cache squeeze_options array per request to avoid repeated database queries
469 // This prevents hundreds of get_option('squeeze_options') calls during bulk operations
470 if ( self::$cached_squeeze_options === null ) {
471 self::$cached_squeeze_options = get_option( 'squeeze_options' );
472 }
473 $options = self::$cached_squeeze_options;
474 $option_value = ( isset( $options[$option] ) ? $options[$option] : $this->get_default_value( $option ) );
475 return $option_value;
476 }
477
478 public function set_options( $options ) {
479 $default_options = $this->get_default_value( 'all', true );
480 $options = wp_parse_args( $options, $default_options );
481 // Strip any keys not present in the known defaults — prevents option-injection attacks.
482 $options = array_intersect_key( $options, $default_options );
483 // Constrain compress_formats to the hardcoded constant so callers cannot introduce
484 // arbitrary extensions (e.g. 'php') that would later be accepted as upload targets.
485 if ( isset( $options['compress_formats'] ) && is_array( $options['compress_formats'] ) ) {
486 $options['compress_formats'] = array_intersect_key( $options['compress_formats'], self::ALLOWED_IMAGE_FORMATS );
487 if ( empty( $options['compress_formats'] ) ) {
488 $options['compress_formats'] = self::ALLOWED_IMAGE_FORMATS;
489 }
490 }
491 $result = update_option( 'squeeze_options', $options );
492 // Clear the cache when options are updated
493 if ( $result ) {
494 self::$cached_squeeze_options = $options;
495 // New settings may produce smaller output — let all previously-failed images be retried
496 $this->clear_compression_failed_meta();
497 }
498 return $result;
499 }
500
501 private function clear_compression_failed_meta() {
502 global $wpdb;
503 $wpdb->delete( $wpdb->postmeta, array(
504 'meta_key' => 'squeeze_compression_failed',
505 ) );
506 }
507
508 /**
509 * Opt-in cleanup of plugin metadata on uninstall.
510 * Static so it can be used with register_uninstall_hook (free) and Freemius after_uninstall (premium).
511 * Does not delete image files (originals, backups, or WebP copies).
512 */
513 public static function uninstall_cleanup() {
514 $options = \get_option( 'squeeze_options', array() );
515 if ( empty( $options['clear_data_on_uninstall'] ) ) {
516 return;
517 }
518 \delete_option( 'squeeze_options' );
519 \delete_option( 'squeeze_stats' );
520 \delete_transient( 'squeeze_bulk_path' );
521 self::$cached_squeeze_options = null;
522 global $wpdb;
523 $wpdb->delete( $wpdb->postmeta, array(
524 'meta_key' => 'squeeze_is_compressed',
525 ) );
526 $wpdb->delete( $wpdb->postmeta, array(
527 'meta_key' => 'squeeze_compression_failed',
528 ) );
529 }
530
531 public function get_comparison_table( $sizes ) {
532 if ( !is_array( $sizes ) || empty( $sizes ) ) {
533 return '';
534 }
535 //$table = print_r($sizes, true);
536 $table = '<div class="squeeze-comparison-table">';
537 $table .= '<table class="wp-list-table widefat striped">';
538 $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>';
539 $table .= '<tbody>';
540 foreach ( $sizes as $size_name => $size_data ) {
541 /*if (!isset($size_data['url'])) {
542 continue;
543 }*/
544 //$size_filename = basename(sanitize_url($size_data['url']));
545 $original_size = $size_data['original_size'];
546 $compressed_size = $size_data['compressed_size'];
547 if ( $original_size <= 0 ) {
548 $savings_percent = esc_html__( 'N/A', 'squeeze' );
549 $savings_class = '';
550 } else {
551 $savings = $original_size - $compressed_size;
552 $savings_percent = round( $savings / $original_size * 100, 2 ) . '%';
553 $savings_class = ( $savings > 0 ? 'squeeze-savings-positive' : 'squeeze-savings-negative' );
554 }
555 $table .= '<tr>';
556 $table .= '<td><strong>' . $size_name . '</strong></td>';
557 $table .= '<td>' . (( $original_size > 0 ? size_format( $original_size, 0 ) : esc_html__( 'N/A', 'squeeze' ) )) . '</td>';
558 $table .= '<td>' . size_format( $compressed_size, 0 ) . '</td>';
559 $table .= '<td><span class="squeeze-savings-label ' . $savings_class . '">' . $savings_percent . '</span></td>';
560 $table .= '</tr>';
561 }
562 $table .= '</tbody></table></div>';
563 return $table;
564 }
565
566 public function is_webp_replace_urls() {
567 $is_auto_webp = $this->get_option( 'auto_webp' );
568 $is_webp_replace_urls = $this->get_option( 'webp_replace_urls' );
569 if ( !$is_auto_webp || !$is_webp_replace_urls ) {
570 return false;
571 }
572 return true;
573 }
574
575 public function get_image_formats( $return_mimes = false, $custom_formats = [] ) {
576 $allowed_image_formats = ( empty( $custom_formats ) ? $this->get_option( 'compress_formats' ) : $custom_formats );
577 // Intersect with the hardcoded constant so a polluted option can never introduce
578 // non-image extensions (e.g. 'php') into the upload allowlist.
579 if ( is_array( $allowed_image_formats ) ) {
580 $allowed_image_formats = array_intersect_key( $allowed_image_formats, self::ALLOWED_IMAGE_FORMATS );
581 }
582 if ( empty( $allowed_image_formats ) ) {
583 $allowed_image_formats = self::ALLOWED_IMAGE_FORMATS;
584 }
585 $allowed_image_formats = array_keys( $allowed_image_formats );
586 // make values the same as keys
587 $allowed_image_formats = array_combine( $allowed_image_formats, $allowed_image_formats );
588 if ( $return_mimes ) {
589 $allowed_image_formats = array_map( function ( $format ) {
590 $format = ( $format === 'jpg' ? 'jpeg' : $format );
591 // handle jpg/jpeg mime type
592 return 'image/' . $format;
593 }, $allowed_image_formats );
594 }
595 return $allowed_image_formats;
596 }
597
598 public function get_total_images_count() {
599 $total_images = $this->get_stats_option( 'total_images' );
600 if ( $total_images > 0 ) {
601 return $total_images;
602 }
603 $query_all = new \WP_Query(array(
604 'post_type' => 'attachment',
605 'post_status' => 'inherit',
606 'post_mime_type' => $this->get_image_formats( true ),
607 'posts_per_page' => -1,
608 'fields' => 'ids',
609 ));
610 $total_images = $query_all->found_posts;
611 $stats['total_images'] = $total_images;
612 update_option( 'squeeze_stats', $stats );
613 return $total_images;
614 }
615
616 /**
617 * Meta query for the Media Library "Non Squeezed Images" filter (list and grid).
618 *
619 * @return array<string, mixed>
620 */
621 public function get_non_squeezed_media_meta_query() {
622 return array(
623 'relation' => 'AND',
624 array(
625 'relation' => 'OR',
626 array(
627 'key' => 'squeeze_is_compressed',
628 'compare' => '!=',
629 'value' => '1',
630 ),
631 array(
632 'key' => 'squeeze_is_compressed',
633 'compare' => 'NOT EXISTS',
634 ),
635 ),
636 array(
637 'relation' => 'OR',
638 array(
639 'key' => 'squeeze_compression_failed',
640 'compare' => 'NOT EXISTS',
641 ),
642 array(
643 'key' => 'squeeze_compression_failed',
644 'value' => 'larger_than_original',
645 'compare' => '!=',
646 ),
647 ),
648 );
649 }
650
651 /**
652 * Meta query shared by bulk uncompressed image queries.
653 *
654 * @return array<string, mixed>
655 */
656 public function get_uncompressed_meta_query() {
657 return array(
658 'relation' => 'AND',
659 array(
660 'relation' => 'OR',
661 array(
662 'key' => 'squeeze_is_compressed',
663 'compare' => 'NOT EXISTS',
664 ),
665 array(
666 'key' => 'squeeze_is_compressed',
667 'compare' => '!=',
668 'value' => '1',
669 ),
670 ),
671 array(
672 'key' => 'squeeze_compression_failed',
673 'compare' => 'NOT EXISTS',
674 ),
675 );
676 }
677
678 /**
679 * @return array<string, mixed>
680 */
681 private function get_uncompressed_images_query_args( $last_id = 0 ) {
682 return array(
683 'post_type' => 'attachment',
684 'post_status' => 'inherit',
685 'post_mime_type' => $this->get_image_formats( true ),
686 'posts_per_page' => self::$MEDIA_PER_PAGE,
687 'fields' => 'ids',
688 'orderby' => 'ID',
689 'order' => 'DESC',
690 'meta_query' => $this->get_uncompressed_meta_query(),
691 'squeeze_last_id' => $last_id,
692 );
693 }
694
695 public function get_uncompressed_images_count() {
696 $uncompressed_images = $this->get_stats_option( 'uncompressed_images' );
697 if ( $uncompressed_images > 0 ) {
698 return $uncompressed_images;
699 }
700 $args = $this->get_uncompressed_images_query_args();
701 $args['posts_per_page'] = -1;
702 $query_uncompressed = new \WP_Query($args);
703 $uncompressed_images = count( $this->filter_excluded_attachment_ids( $query_uncompressed->posts ) );
704 $stats['uncompressed_images'] = $uncompressed_images;
705 update_option( 'squeeze_stats', $stats );
706 return $uncompressed_images;
707 }
708
709 public function get_images_from_last_id( $where, $wp_query ) {
710 if ( $wp_query->get( 'squeeze_last_id' ) !== null && $wp_query->get( 'squeeze_last_id' ) > 0 ) {
711 global $wpdb;
712 $last = intval( $wp_query->get( 'squeeze_last_id' ) );
713 $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $last );
714 }
715 return $where;
716 }
717
718 public function get_uncompressed_images( $last_id = 0 ) {
719 $excluded_images = $this->get_excluded_images();
720 $results = array();
721 $current_last_id = $last_id;
722 // Cap how many batches we scan while backfilling after exclusions (~20 default pages of candidates).
723 $max_iterations = max( 2, (int) ceil( 1000 / self::$MEDIA_PER_PAGE ) );
724 while ( count( $results ) < self::$MEDIA_PER_PAGE && $max_iterations-- > 0 ) {
725 $query = new \WP_Query($this->get_uncompressed_images_query_args( $current_last_id ));
726 $posts = $query->posts;
727 if ( empty( $posts ) ) {
728 break;
729 }
730 $batch = ( empty( $excluded_images ) ? $posts : $this->filter_excluded_attachment_ids( $posts, $excluded_images ) );
731 $results = array_merge( $results, $batch );
732 if ( count( $posts ) < self::$MEDIA_PER_PAGE ) {
733 break;
734 }
735 $current_last_id = (int) end( $posts );
736 }
737 return array_slice( array_values( array_unique( array_map( 'intval', $results ) ) ), 0, self::$MEDIA_PER_PAGE );
738 }
739
740 public function get_total_images( $paged = 1 ) {
741 $args = array(
742 'post_type' => 'attachment',
743 'post_status' => 'inherit',
744 'post_mime_type' => $this->get_image_formats( true ),
745 'posts_per_page' => self::$MEDIA_PER_PAGE,
746 'paged' => $paged,
747 'fields' => 'ids',
748 );
749 $query_all = new \WP_Query($args);
750 return $query_all->posts;
751 }
752
753 public function get_hint( $hint, $class = 'squeeze-hint' ) {
754 return '<span class="' . esc_attr( $class ) . '">' . esc_html( $hint ) . '</span>';
755 }
756
757 /**
758 * Parsed list of exclusion patterns (one per line in settings). Cached per request.
759 *
760 * @return string[]
761 */
762 public function get_excluded_images() {
763 if ( null !== self::$cached_excluded_images ) {
764 return self::$cached_excluded_images;
765 }
766 $raw = $this->get_option( 'excluded_images' );
767 if ( !is_string( $raw ) || $raw === '' ) {
768 self::$cached_excluded_images = array();
769 return self::$cached_excluded_images;
770 }
771 $lines = explode( "\n", $raw );
772 $lines = array_filter( array_map( 'trim', $lines ), static function ( $line ) {
773 return $line !== '';
774 } );
775 self::$cached_excluded_images = array_values( $lines );
776 return self::$cached_excluded_images;
777 }
778
779 /**
780 * Whether a URL or path matches any exclusion pattern (substring match, case-insensitive).
781 *
782 * @param string|null $image_path URL or path fragment.
783 * @param string[]|null $excluded_images Patterns from get_excluded_images(); null loads from options.
784 * @return array{is_excluded: true, exclude_reason: string}|false
785 */
786 public function is_excluded_image( $image_path, $excluded_images = null ) {
787 if ( !$image_path ) {
788 return false;
789 }
790 if ( null === $excluded_images ) {
791 $excluded_images = $this->get_excluded_images();
792 }
793 if ( empty( $excluded_images ) ) {
794 return false;
795 }
796 foreach ( $excluded_images as $excluded_image ) {
797 $excluded_image = trim( $excluded_image );
798 if ( $excluded_image === '' ) {
799 continue;
800 }
801 if ( stripos( $image_path, $excluded_image ) !== false ) {
802 return array(
803 'is_excluded' => true,
804 'exclude_reason' => $excluded_image,
805 );
806 }
807 }
808 return false;
809 }
810
811 /**
812 * Whether a media-library attachment matches excluded-images settings.
813 *
814 * @param int $attachment_id Attachment post ID.
815 * @param string[]|null $excluded_images Patterns from get_excluded_images(); null loads from options.
816 */
817 public function is_attachment_excluded( $attachment_id, $excluded_images = null ) {
818 if ( !$attachment_id || 'attachment' !== get_post_type( $attachment_id ) ) {
819 return false;
820 }
821 if ( null === $excluded_images ) {
822 $excluded_images = $this->get_excluded_images();
823 }
824 if ( empty( $excluded_images ) ) {
825 return false;
826 }
827 $candidates = array_filter( array(wp_get_attachment_url( $attachment_id ), wp_get_original_image_url( $attachment_id ), get_attached_file( $attachment_id )) );
828 $full_image = wp_get_attachment_image_src( $attachment_id, 'full' );
829 if ( !empty( $full_image[0] ) ) {
830 $candidates[] = $full_image[0];
831 }
832 foreach ( array_unique( $candidates ) as $candidate ) {
833 if ( $this->is_excluded_image( $candidate, $excluded_images ) ) {
834 return true;
835 }
836 }
837 return false;
838 }
839
840 /**
841 * @param int[] $attachment_ids
842 * @param string[]|null $excluded_images
843 * @return int[]
844 */
845 public function filter_excluded_attachment_ids( array $attachment_ids, $excluded_images = null ) {
846 if ( empty( $attachment_ids ) ) {
847 return $attachment_ids;
848 }
849 if ( null === $excluded_images ) {
850 $excluded_images = $this->get_excluded_images();
851 }
852 if ( empty( $excluded_images ) ) {
853 return $attachment_ids;
854 }
855 return array_values( array_filter( $attachment_ids, function ( $attachment_id ) use($excluded_images) {
856 return !$this->is_attachment_excluded( (int) $attachment_id, $excluded_images );
857 } ) );
858 }
859
860 public function clear_excluded_images_cache() {
861 self::$cached_excluded_images = null;
862 }
863
864 public function clear_options_cache() {
865 self::$cached_squeeze_options = null;
866 }
867
868 public function invalidate_uncompressed_stats_cache() {
869 $stats = get_option( 'squeeze_stats', array() );
870 if ( isset( $stats['uncompressed_images'] ) ) {
871 unset($stats['uncompressed_images']);
872 update_option( 'squeeze_stats', $stats );
873 }
874 }
875
876 public function get_default_value( $option, $all = false ) {
877 $options_defaults = apply_filters( 'squeeze_options_default', array(
878 'jpeg_quality' => 80,
879 'jpeg_baseline' => false,
880 'jpeg_progressive' => true,
881 'jpeg_optimize_coding' => true,
882 'jpeg_smoothing' => 0,
883 'jpeg_color_space' => 3,
884 'jpeg_quant_table' => 3,
885 'jpeg_trellis_multipass' => false,
886 'jpeg_trellis_opt_zero' => false,
887 'jpeg_trellis_opt_table' => false,
888 'jpeg_trellis_loops' => 1,
889 'jpeg_auto_subsample' => true,
890 'jpeg_chroma_subsample' => 2,
891 'jpeg_separate_chroma_quality' => false,
892 'jpeg_chroma_quality' => 75,
893 'png_level' => 2,
894 'png_interlace' => false,
895 'png_quality' => 0.7,
896 'webp_method' => 4,
897 'webp_quality' => 80,
898 'webp_lossless' => false,
899 'webp_near_lossless' => 100,
900 'avif_cqLevel' => 70,
901 'auto_compress' => true,
902 'auto_webp' => false,
903 'webp_replace_urls' => false,
904 'direct_webp' => true,
905 'cdn_url' => '',
906 'backup_original' => true,
907 'clear_data_on_uninstall' => false,
908 'compress_formats' => self::ALLOWED_IMAGE_FORMATS,
909 'compress_thumbs' => array(
910 'large' => 'on',
911 'full' => 'on',
912 ),
913 'max_width' => '',
914 'max_height' => '',
915 'excluded_images' => '',
916 'timeout' => 60,
917 'restore_defaults' => false,
918 ) );
919 if ( $all ) {
920 return $options_defaults;
921 }
922 return ( in_array( $option, array_keys( $options_defaults ) ) ? $options_defaults[$option] : false );
923 }
924
925 public function get_thumb_sizes() {
926 $sizes = wp_get_registered_image_subsizes();
927 if ( !empty( $sizes ) && is_array( $sizes ) ) {
928 // Add the scaled image size option if it fits the image dimensions
929 $big_image_size_threshold = apply_filters( 'big_image_size_threshold', 2560 );
930 if ( $big_image_size_threshold ) {
931 $sizes['full'] = array(
932 'width' => $big_image_size_threshold,
933 'height' => $big_image_size_threshold,
934 'crop' => false,
935 );
936 }
937 }
938 return $sizes;
939 }
940
941 public function is_rest_enabled() {
942 // Check if REST API is enabled
943 if ( !function_exists( 'rest_get_server' ) || !rest_get_server() ) {
944 return false;
945 }
946 return (bool) apply_filters( 'rest_enabled', true );
947 }
948
949 /**
950 * List Apache modules when PHP can report them (typically mod_php only).
951 *
952 * @return array|null Module list, or null when undetectable (CGI/FPM/nginx/etc.).
953 */
954 public function apache_get_modules() {
955 if ( !function_exists( 'apache_get_modules' ) ) {
956 return null;
957 }
958 return apache_get_modules();
959 }
960
961 /**
962 * Convert a filesystem directory under ABSPATH to site-root-relative form (/wp-content/foo/).
963 *
964 * @param string $filesystem_dir Absolute directory path.
965 * @return string Leading slash, forward slashes, trailing slash (or '/' for site root).
966 */
967 public function bulk_directory_uri_from_filesystem( $filesystem_dir ) {
968 $abs_base = wp_normalize_path( ABSPATH );
969 $full = wp_normalize_path( rtrim( (string) $filesystem_dir, '/\\' ) );
970 if ( strpos( $full, $abs_base ) !== 0 ) {
971 $slash = preg_replace( '#/+#', '/', str_replace( '\\', '/', str_replace( ABSPATH, '/', (string) $filesystem_dir ) ) );
972 if ( '/' === $slash || '' === $slash ) {
973 return '/';
974 }
975 if ( '/' !== substr( $slash, 0, 1 ) ) {
976 $slash = '/' . ltrim( $slash, '/' );
977 }
978 return ( substr( $slash, -1 ) === '/' ? $slash : $slash . '/' );
979 }
980 $rel = substr( $full, strlen( $abs_base ) );
981 $rel = trim( str_replace( '\\', '/', $rel ), '/' );
982 return ( '' === $rel ? '/' : '/' . $rel . '/' );
983 }
984
985 /**
986 * Turn browse API parentDir into a path relative to WP_CONTENT_DIR (uploads, themes/foo, or '').
987 *
988 * @param string $parent_directory Raw POST value (e.g. /wp-content/uploads/ or wp-content/uploads).
989 * @return string No leading/trailing slashes.
990 */
991 public function bulk_parent_relative_to_content_dir( $parent_directory ) {
992 $parent_directory = preg_replace( '#/+#', '/', str_replace( '\\', '/', (string) $parent_directory ) );
993 $parent_directory = trim( $parent_directory, '/' );
994 if ( '' === $parent_directory ) {
995 return '';
996 }
997 if ( 'wp-content' === $parent_directory ) {
998 return '';
999 }
1000 if ( 0 === strpos( $parent_directory, 'wp-content/' ) ) {
1001 return trim( substr( $parent_directory, strlen( 'wp-content/' ) ), '/' );
1002 }
1003 return $parent_directory;
1004 }
1005
1006 /**
1007 * Normalize directory paths for bulk UI and JSON: never show filesystem absolute paths.
1008 *
1009 * @param string $path Raw path from transient, manual entry, etc.
1010 * @return string Site-relative path like /wp-content/uploads/.
1011 */
1012 public function normalize_bulk_directory_storage_path( $path ) {
1013 $path = trim( (string) $path );
1014 if ( '' === $path ) {
1015 return '/';
1016 }
1017 if ( preg_match( '#^https?://#i', $path ) ) {
1018 $parsed = wp_parse_url( $path );
1019 $path = ( isset( $parsed['path'] ) ? $parsed['path'] : '/' );
1020 $site = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
1021 if ( is_string( $site ) && strlen( $site ) > 1 ) {
1022 $site = untrailingslashit( $site );
1023 if ( $site && strpos( $path, $site . '/' ) === 0 ) {
1024 $path = substr( $path, strlen( $site ) );
1025 }
1026 }
1027 }
1028 $clean = preg_replace( '#/+#', '/', str_replace( '\\', '/', $path ) );
1029 $norm = wp_normalize_path( $clean );
1030 $base = wp_normalize_path( ABSPATH );
1031 if ( strlen( $norm ) >= 2 && ctype_alpha( $norm[0] ) && ':' === $norm[1] || strpos( $norm, $base ) === 0 ) {
1032 return $this->bulk_directory_uri_from_filesystem( $clean );
1033 }
1034 if ( '/' !== substr( $clean, 0, 1 ) ) {
1035 $clean = '/' . ltrim( $clean, '/' );
1036 }
1037 if ( '/' !== substr( $clean, -1 ) ) {
1038 $clean .= '/';
1039 }
1040 return $clean;
1041 }
1042
1043 }
1044