diff --git a/docs/hooks/tiny_image_before_compression.md b/docs/hooks/tiny_image_before_compression.md index 212ae549..79f8cf08 100644 --- a/docs/hooks/tiny_image_before_compression.md +++ b/docs/hooks/tiny_image_before_compression.md @@ -8,14 +8,22 @@ Action that runs before compressing an single attachment. ## Arguments 1. `int $attachment_id` - The attachment ID. +2. `array|null $wp_metadata` - The attachment metadata. + +The metadata is passed along because WordPress has not necessarily stored it +yet. On upload the compression runs from within +`wp_generate_attachment_metadata`, so `wp_get_attachment_metadata()` can still +be empty at this point. Use the passed metadata instead of looking it up. ## Example ```php add_action( 'tiny_image_before_compression', - function ( $id ) { + function ( $id, $wp_metadata ) { // notify system of compression - } + }, + 10, + 2 ); ``` diff --git a/src/class-tiny-diagnostics.php b/src/class-tiny-diagnostics.php index 73f9c710..6305bfe1 100644 --- a/src/class-tiny-diagnostics.php +++ b/src/class-tiny-diagnostics.php @@ -206,7 +206,13 @@ public function create_diagnostic_zip() { } $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); - $temp_dir = trailingslashit( get_temp_dir() ) . 'tiny-compress-temp'; + if ( false === $wp_filesystem ) { + return new WP_Error( + 'filesystem_unavailable', + __( 'WordPress filesystem could not be initialized.', 'tiny-compress-images' ) + ); + } + $temp_dir = trailingslashit( get_temp_dir() ) . 'tiny-compress-temp'; if ( ! $wp_filesystem->exists( $temp_dir ) ) { wp_mkdir_p( $temp_dir ); } @@ -249,6 +255,14 @@ public function create_diagnostic_zip() { */ public static function download_zip( $zip_path ) { $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + wp_die( + esc_html__( + 'WordPress filesystem could not be initialized.', + 'tiny-compress-images' + ) + ); + } if ( ! $wp_filesystem->exists( $zip_path ) ) { wp_die( esc_html__( 'Diagnostic file not found.', 'tiny-compress-images' ) ); } diff --git a/src/class-tiny-helpers.php b/src/class-tiny-helpers.php index dc4f41fb..4a5b4747 100644 --- a/src/class-tiny-helpers.php +++ b/src/class-tiny-helpers.php @@ -115,12 +115,10 @@ public static function get_mimetype( $input ) { * Gets or initializes the WordPress filesystem instance. * * Returns the global WP_Filesystem instance, initializing it if necessary. - * This helper prevents repeated initialization code throughout the plugin. * * @since 3.7.0 * - * @return WP_Filesystem_Base The WP_Filesystem instance. - * @throws Exception If the filesystem cannot be initialized. + * @return WP_Filesystem_Base|false The WP_Filesystem instance, or false on failure. */ public static function get_wp_filesystem() { global $wp_filesystem; @@ -129,14 +127,15 @@ public static function get_wp_filesystem() { return $wp_filesystem; } - // Initialize the filesystem only if the function isn't available yet. if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); if ( ! ( $wp_filesystem instanceof WP_Filesystem_Base ) ) { - throw new Exception( 'Unable to initialize WordPress filesystem.' ); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Tiny Compress: Unable to initialize WordPress filesystem.' ); + return false; } return $wp_filesystem; diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index 4b349eab..60fac156 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -234,13 +234,19 @@ public function compress() { /** * Fires before an image is sent for compression. * + * The metadata is passed along because it has not necessarily been + * stored yet. On upload the compression runs from within + * `wp_generate_attachment_metadata`, before WordPress saves it. + * * @since 3.7.0 * - * @param int $attachment_id The attachment ID + * @param int $attachment_id The attachment ID + * @param array $wp_metadata The attachment metadata */ do_action( 'tiny_image_before_compression', - $this->id + $this->id, + $this->wp_metadata ); $success = 0; @@ -675,4 +681,224 @@ public function mark_as_compressed() { $this->update_tiny_post_meta(); } + + /** + * Retrieves the original image of the Tiny_Image + * + * + * @return Tiny_Image_Size|false the image or false if does not exist + */ + private function get_original_image() { + $original_image = $this->get_image_size( self::ORIGINAL_UNSCALED ); + if ( null === $original_image ) { + $original_image = $this->get_image_size(); + } + + if ( null === $original_image ) { + return false; + } + + return $original_image; + } + + /** + * Builds the filesystem path where the backup of the original image is + * (or would be) stored. + * + * @return string|false the backup file path, or false if there is no original image + */ + private function get_backup_path() { + $original_image = $this->get_original_image(); + if ( false === $original_image ) { + return false; + } + + $file_path = $original_image->filename; + $upload_dir = wp_upload_dir(); + $basedir = trailingslashit( $upload_dir['basedir'] ); + if ( Tiny_Helpers::str_starts_with( $file_path, $basedir ) ) { + $file_path = substr( $file_path, strlen( $basedir ) ); + } + + return $basedir . 'tinify_backup/' . $file_path; + } + + /** + * Creates a backup copy of the original image, if one does not already exist. + * + * @return bool true on success, false on failure or if a backup already exists + */ + public function create_backup() { + + $backup_file_path = $this->get_backup_path(); + if ( false === $backup_file_path ) { + return false; + } + + $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return false; + } + + if ( $wp_filesystem->exists( $backup_file_path ) ) { + return false; + } + + $backup_dir = dirname( $backup_file_path ); + + if ( ! wp_mkdir_p( $backup_dir ) ) { + return false; + } + + $original_image = $this->get_original_image(); + + return $wp_filesystem->copy( $original_image->filename, $backup_file_path ); + } + + + /** + * Retrieves the public URL of the backup of the original image. + * + * @return string|false the backup URL, or false if no backup exists + */ + public function get_backup() { + $backup_file_path = $this->get_backup_path(); + if ( false === $backup_file_path ) { + return false; + } + + $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return false; + } + + if ( ! $wp_filesystem->exists( $backup_file_path ) ) { + return false; + } + + $upload_dir = wp_upload_dir(); + $basedir = trailingslashit( $upload_dir['basedir'] ); + $baseurl = trailingslashit( $upload_dir['baseurl'] ); + + return str_replace( $basedir, $baseurl, $backup_file_path ); + } + + /** + * Regenerates all thumbnail sizes of an attachment from a file. + * + * Uses wp_create_image_subsizes() when available, which does not apply + * the `wp_generate_attachment_metadata` filter. That filter is what starts + * a compression and the image that has just been restored should be left alone. + * + * @since 3.7.0 + * + * @param string $filename Absolute path of the image to regenerate from. + * @return array The regenerated attachment metadata. + */ + private function regenerate_sizes( $filename ) { + // https://developer.wordpress.org/reference/functions/wp_create_image_subsizes/ + if ( function_exists( 'wp_create_image_subsizes' ) ) { + return wp_create_image_subsizes( $filename, $this->id ); + } + + /* + WordPress < 5.3 has no way of regenerating the sizes without applying + the filter, so it is disabled for the duration of the call. */ + global $tiny_plugin; + $compress_on_upload = ( $tiny_plugin instanceof Tiny_Plugin ) + ? array( $tiny_plugin, 'process_attachment' ) + : null; + + if ( $compress_on_upload ) { + remove_filter( 'wp_generate_attachment_metadata', $compress_on_upload, 10 ); + } + + // https://developer.wordpress.org/reference/functions/wp_generate_attachment_metadata/ + $metadata = wp_generate_attachment_metadata( $this->id, $filename ); + + if ( $compress_on_upload ) { + add_filter( 'wp_generate_attachment_metadata', $compress_on_upload, 10, 2 ); + } + + return $metadata; + } + + /** + * Restores the original image from its backup. + * + * - Copies the backup file over the current original. + * - Clears compression metadata for all image sizes. + * - Regenerates all thumbnail sizes from the restored image. + * - Updates the WordPress attachment metadata. + * + * @since 3.7.0 + * + * @return bool True on success, false if no backup exists or the copy fails. + */ + public function restore_backup() { + $backup_file_path = $this->get_backup_path(); + if ( false === $backup_file_path ) { + return false; + } + + $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return false; + } + + if ( ! $wp_filesystem->exists( $backup_file_path ) ) { + return false; + } + + $original_image = $this->get_original_image(); + if ( false === $original_image ) { + return false; + } + + if ( ! $wp_filesystem->copy( $backup_file_path, $original_image->filename, true ) ) { + return false; + } + + // Clear compression metadata for all image sizes. + foreach ( $this->sizes as $size ) { + $size->meta = array(); + } + $this->update_tiny_post_meta(); + + // Regenerate all thumbnail sizes from the restored image. + $new_metadata = $this->regenerate_sizes( $original_image->filename ); + if ( $new_metadata ) { + $this->wp_metadata = $new_metadata; + wp_update_attachment_metadata( $this->id, $this->wp_metadata ); + $this->sizes = array(); + $this->parse_wp_metadata(); + } + + return true; + } + + /** + * Deletes the backup file of the original image, if it exists. + * + * @since 3.7.0 + * + * @return bool True on success or if no backup exists, false on deletion failure. + */ + public function delete_backup() { + $backup_file_path = $this->get_backup_path(); + if ( false === $backup_file_path ) { + return true; + } + + $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return false; + } + + if ( ! $wp_filesystem->exists( $backup_file_path ) ) { + return true; + } + + return $wp_filesystem->delete( $backup_file_path ); + } } diff --git a/src/class-tiny-logger.php b/src/class-tiny-logger.php index b50da5be..da0efa2b 100644 --- a/src/class-tiny-logger.php +++ b/src/class-tiny-logger.php @@ -179,6 +179,9 @@ private function log( $level, $message, $context = array() ) { // Ensure log directory exists. $log_dir = dirname( $this->log_file_path ); $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return; + } if ( ! $wp_filesystem->exists( $log_dir ) ) { wp_mkdir_p( $log_dir ); self::create_blocking_files( $log_dir ); @@ -201,6 +204,9 @@ private function log( $level, $message, $context = array() ) { */ private function rotate_logs() { $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return; + } if ( ! $wp_filesystem->exists( $this->log_file_path ) ) { return; } @@ -222,7 +228,10 @@ public static function clear_logs() { $instance = self::get_instance(); $log_path = $instance->get_log_file_path(); $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); - $file_exits = $wp_filesystem->exists( $log_path ); + if ( false === $wp_filesystem ) { + return false; + } + $file_exits = $wp_filesystem->exists( $log_path ); if ( $file_exits ) { return $wp_filesystem->delete( $log_path ); } @@ -239,6 +248,9 @@ public static function clear_logs() { */ private static function create_blocking_files( $log_dir ) { $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); + if ( false === $wp_filesystem ) { + return; + } $index_file = trailingslashit( $log_dir ) . 'index.html'; if ( ! $wp_filesystem->exists( $index_file ) ) { diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index a96b9f4f..41dde4b7 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -72,7 +72,7 @@ public function init() { 'tiny_image_before_compression', $this->get_method( 'backup_original_image' ), 10, - 1 + 2 ); load_plugin_textdomain( @@ -119,6 +119,11 @@ public function ajax_init() { $this->get_method( 'mark_image_as_compressed' ) ); + add_action( + 'wp_ajax_tiny_restore_backup', + $this->get_method( 'restore_backup_image' ) + ); + /* When touching any functionality linked to image compressions when uploading images make sure it also works with XML-RPC. See README. */ @@ -746,7 +751,7 @@ public function render_media_column( $column, $id ) { if ( self::MEDIA_COLUMN === $column ) { $tiny_image = new Tiny_Image( $this->settings, $id ); if ( $tiny_image->file_type_allowed() ) { - echo '
'; + echo '
'; $this->render_compress_details( $tiny_image ); echo '
'; } @@ -761,7 +766,8 @@ public function show_media_info() { echo '

'; esc_html_e( 'JPEG, PNG, & WebP optimization', 'tiny-compress-images' ); echo '

'; - echo '
'; + echo '
'; $this->render_compress_details( $tiny_image ); echo '
'; echo '
'; @@ -909,6 +915,7 @@ public function friendly_user_name() { public function clean_attachment( $post_id ) { $tiny_image = new Tiny_Image( $this->settings, $post_id ); $tiny_image->delete_converted_image(); + $tiny_image->delete_backup(); } /** @@ -927,44 +934,14 @@ public function clean_attachment( $post_id ) { * @param int $attachment_id The ID of the attachment * @return bool return true on backup created */ - public function backup_original_image( $attachment_id ) { + public function backup_original_image( $attachment_id, $wp_metadata = null ) { if ( ! $this->settings->get_backup_enabled() ) { return false; } - $tiny_image = new Tiny_Image( $this->settings, $attachment_id ); - - $original_image = $tiny_image->get_image_size( Tiny_Image::ORIGINAL_UNSCALED ); - if ( null === $original_image ) { - $original_image = $tiny_image->get_image_size(); - } - - if ( null === $original_image ) { - return false; - } - - $file_path = $original_image->filename; - $upload_dir = wp_upload_dir(); - $basedir = trailingslashit( $upload_dir['basedir'] ); - if ( Tiny_Helpers::str_starts_with( $file_path, $basedir ) ) { - $file_path = substr( $file_path, strlen( $basedir ) ); - } - - $backup_file = $basedir . 'tinify_backup/' . $file_path; - - $wp_filesystem = Tiny_Helpers::get_wp_filesystem(); - - if ( $wp_filesystem->exists( $backup_file ) ) { - return false; - } + $tiny_image = new Tiny_Image( $this->settings, $attachment_id, $wp_metadata ); - $backup_dir = dirname( $backup_file ); - - if ( ! wp_mkdir_p( $backup_dir ) ) { - return false; - } - - return $wp_filesystem->copy( $original_image->filename, $backup_file ); + return $tiny_image->create_backup(); } public static function request_review() { @@ -989,6 +966,39 @@ public static function uninstall() { Tiny_Apache_Rewrite::uninstall_rules(); } + /** + * Restores the original image from its backup via AJAX. + * + * Validates the request, calls restore_backup() on the image, then + * re-renders the compression details partial in the response. + * + * @since 3.7.0 + * + * @return void + */ + public function restore_backup_image() { + $response = $this->validate_ajax_attachment_request(); + if ( isset( $response['error'] ) ) { + echo esc_html( $response['error'] ); + exit(); + } + + list($id, $metadata) = $response['data']; + $tiny_image = new Tiny_Image( $this->settings, $id, $metadata ); + + if ( ! $tiny_image->restore_backup() ) { + echo esc_html__( + 'Could not restore backup. The backup file may not exist or could not be written.', + 'tiny-compress-images' + ); + exit(); + } + + $this->render_compress_details( $tiny_image ); + + exit(); + } + public function mark_image_as_compressed() { $response = $this->validate_ajax_attachment_request(); if ( isset( $response['error'] ) ) { diff --git a/src/css/admin.css b/src/css/admin.css index 977dadf1..6f70b5e8 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -412,7 +412,7 @@ input[type=number][name*="tinypng_resize_original"] { } .tiny-compression-details { - padding: 10px; + padding: 10px 0; } .tiny-compression-details table { @@ -481,4 +481,45 @@ fieldset.tinypng_convert_fields[disabled] { .tiny-mt-2 { margin-top: 10px; +} + +.tiny-dialog { + padding: 20px; + border: 1px solid #ccd0d4; + border-radius: 4px; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); +} + +.tiny-dialog-error { + color: #dc3232; +} + +.tiny-dialog-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.tiny-dialog-title { + font-size: 1.3rem; +} + +.tiny-compression-details .tiny-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 20px; + background: #F1F7FF; + border: 1px solid #e1e1e1; +} + +.tiny-card a.button { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tiny-icon-backup { + flex-shrink: 0; + fill: currentColor; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index 03312c0a..cac5712b 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -1,4 +1,71 @@ -(function() { +(function () { + async function restoreBackup(attachmentId) { + return jQuery.ajax({ + url: ajaxurl, + type: 'POST', + data: { + _nonce: tinyCompress.nonce, + action: 'tiny_restore_backup', + id: attachmentId, + }, + }); + } + + jQuery(document).on('click', 'a[data-dialog-id]', function (e) { + e.preventDefault(); + const trigger = jQuery(e.currentTarget); + const dialogID = trigger.data('dialog-id'); + if (!dialogID) { + return; + } + + const dialog = document.getElementById(dialogID); + if (!dialog) { + return; + } + + const attachmentId = trigger.data('id'); + const container = document.querySelector(`[data-tiny-media-id="${attachmentId}"]`); + const confirmButton = dialog.querySelector('button[value="submit"]'); + + dialog.showModal(); + + if (confirmButton) { + confirmButton.onclick = async () => { + const spinner = dialog.querySelector('.spinner'); + try { + if (spinner) { + spinner.style.visibility = 'visible'; + } + const result = await restoreBackup(attachmentId); + dialog.close(); + + // refresh thickbox + const modal = container.querySelector('.modal'); + const ajaxContent = document.getElementById('TB_ajaxContent'); + if (modal && ajaxContent) { + modal.append(...ajaxContent.children); + } + + container.innerHTML = result; + if (typeof tb_remove === 'function') { + tb_remove(); + } + } catch (err) { + const errorEl = dialog.querySelector('.tiny-dialog-error'); + if (errorEl) { + errorEl.textContent = err.responseText || 'Failed to restore backup.'; + errorEl.hidden = false; + } + } finally { + if (spinner) { + spinner.style.visibility = 'hidden'; + } + } + }; + } + }); + function downloadDiagnostics() { try { jQuery('#download-diagnostics-spinner').show(); diff --git a/src/views/compress-details-backup.php b/src/views/compress-details-backup.php new file mode 100644 index 00000000..f589eab0 --- /dev/null +++ b/src/views/compress-details-backup.php @@ -0,0 +1,50 @@ +settings->get_backup_enabled(); + +?> +get_backup(); + $modal_id = 'modal_' . absint( $tiny_image->get_id() ) . '_backup'; + ?> + +
+


+ + + +

+ + + + + + +

+ + +
+ + + +
+
+ + + + + +
+ \ No newline at end of file diff --git a/src/views/compress-details.php b/src/views/compress-details.php index 7c3152f9..d9b486a9 100644 --- a/src/views/compress-details.php +++ b/src/views/compress-details.php @@ -10,6 +10,7 @@ $available_sizes = array_keys( $this->settings->get_sizes() ); $conversion_enabled = $this->settings->get_conversion_enabled(); $active_sizes = $this->settings->get_sizes(); +$backup_enabled = $this->settings->get_backup_enabled(); $active_tinify_sizes = $this->settings->get_active_tinify_sizes(); $error = $tiny_image->get_latest_error(); $total = $tiny_image->get_count( array( 'modified', 'missing', 'has_been_compressed', 'compressed', 'has_been_converted' ) ); @@ -19,8 +20,9 @@ $size_before = $image_statistics['initial_total_size']; $size_after = $image_statistics['compressed_total_size']; -$size_active = array_fill_keys( $active_tinify_sizes, true ); -$size_exists = array_fill_keys( $available_sizes, true ); +$size_active = array_fill_keys( $active_tinify_sizes, true ); +$size_exists = array_fill_keys( $available_sizes, true ); +$has_compressed = $size_before - $size_after; ksort( $size_exists ); ?> @@ -111,8 +113,12 @@
- - + get_name() ); + ?> +
@@ -138,12 +144,6 @@