diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index 4b349eab..07e2435e 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -675,4 +675,174 @@ 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 ( $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 ( ! $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 ); + } + + /** + * 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 ( ! $wp_filesystem->exists( $backup_file_path ) ) { + return false; + } + + $original_size_key = null !== $this->get_image_size( self::ORIGINAL_UNSCALED ) + ? self::ORIGINAL_UNSCALED + : self::ORIGINAL; + + $original_image = $this->get_image_size( $original_size_key ); + if ( null === $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 = wp_generate_attachment_metadata( $this->id, $original_image->filename ); + if ( $new_metadata ) { + $this->wp_metadata = $new_metadata; + wp_update_attachment_metadata( $this->id, $this->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 ( ! $wp_filesystem->exists( $backup_file_path ) ) { + return true; + } + + return $wp_filesystem->delete( $backup_file_path ); + } } diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index a96b9f4f..6555d606 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -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. */ @@ -909,6 +914,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(); } /** @@ -934,37 +940,7 @@ public function backup_original_image( $attachment_id ) { $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; - } - - $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 +965,36 @@ 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..ad3aaf34 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,17 @@ 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-actions { + display: flex; + gap: 10px; + justify-content: flex-end; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index 03312c0a..f136f87a 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -1,4 +1,55 @@ -(function() { +(function () { + function restoreBackup(attachmentId, container) { + container.css('opacity', '0.5'); + jQuery.ajax({ + url: ajaxurl, + type: 'POST', + data: { + _nonce: tinyCompress.nonce, + action: 'tiny_restore_backup', + id: attachmentId, + }, + success: function (data) { + container.css('opacity', '1'); + container.html(data); + }, + error: function () { + container.css('opacity', '1'); + // TODO: Replace when actual message + container.html('
Could not restore backup. Please try again.
'); + }, + }); + } + + 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 = jQuery('#modal_' + attachmentId).closest('.tiny-ajax-container'); + + dialog.showModal(); + + dialog.addEventListener('close', function onClose() { + dialog.removeEventListener('close', onClose); + if (dialog.returnValue === 'confirm') { + if (typeof tb_remove === 'function') { + tb_remove(); + } + restoreBackup(attachmentId, container); + } + }); + }); + 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..573b0af0 --- /dev/null +++ b/src/views/compress-details-backup.php @@ -0,0 +1,43 @@ +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..c27c3b3f 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' ) ); @@ -111,8 +112,12 @@| @@ -258,5 +257,6 @@ ?> + diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index 41972e61..3aaa007c 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -92,6 +92,8 @@ public function __construct($vfs) $this->addMethod('is_multisite'); $this->addMethod('current_user_can'); $this->addMethod('wp_get_attachment_metadata'); + $this->addMethod('wp_generate_attachment_metadata'); + $this->addMethod('wp_update_attachment_metadata'); $this->addMethod('is_admin'); $this->addMethod('is_customize_preview'); $this->addMethod('is_plugin_active'); @@ -305,14 +307,11 @@ public function stub($method, $func) public function createImage($file_size, $path, $name) { - if (! $this->vfs->hasChild(self::UPLOAD_DIR . "/$path")) { - vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs); + $full_dir = $this->vfs->url() . '/' . self::UPLOAD_DIR . '/' . $path; + if (! is_dir($full_dir)) { + mkdir($full_dir, 0777, true); } - $dir = $this->vfs->getChild(self::UPLOAD_DIR . "/$path"); - - vfsStream::newFile($name) - ->withContent(new LargeFileContent($file_size)) - ->at($dir); + file_put_contents($full_dir . '/' . $name, str_repeat("\x00", $file_size)); } /** diff --git a/test/unit/TinyPluginBackupTest.php b/test/unit/TinyPluginBackupTest.php index 5e3a28b4..8b98e2d8 100644 --- a/test/unit/TinyPluginBackupTest.php +++ b/test/unit/TinyPluginBackupTest.php @@ -4,6 +4,7 @@ use function PHPUnit\Framework\assertFalse; use function PHPUnit\Framework\assertTrue; +use function PHPUnit\Framework\assertEquals; class Tiny_Plugin_Backup_Test extends Tiny_TestCase { @@ -146,4 +147,115 @@ public function test_will_backup_unscaled_original_when_exists() assertTrue($backup_made, 'expected backup of unscaled original to be made'); assertTrue(file_exists($expected_backup), 'expected backup of unscaled original to be created'); } + + public function test_restore_backup_returns_false_when_no_backup_exists() + { + $this->wp->createImage( 37857, '2026/04', 'testfile.png' ); + + $wp_metadata = array( + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + $mock_settings = $this->createMock( Tiny_Settings::class ); + $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, null, array(), array() ); + + $result = $tiny_image->restore_backup(); + + assertFalse( $result, 'expected restore to return false when no backup exists' ); + } + + public function test_restore_backup_restores_file_from_backup() + { + // Create only the directory (via a placeholder) so the restore can write testfile.png + // without having to overwrite a LargeFileContent vfsStream file, which is read-only. + $this->wp->createImage( 1, '2026/04', '_placeholder.png' ); + $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' ); + + $this->wp->stub( 'wp_generate_attachment_metadata', function ( $id, $file ) { + return array( + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + } ); + + $wp_metadata = array( + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + $mock_settings = $this->createMock( Tiny_Settings::class ); + $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, null, array(), array() ); + + $result = $tiny_image->restore_backup(); + + $original_path = $this->vfs->url() . '/wp-content/uploads/2026/04/testfile.png'; + assertTrue( $result, 'expected restore to return true' ); + assertEquals( 100000, filesize( $original_path ), 'expected original file to be overwritten with backup content' ); + } + + public function test_restore_backup_clears_all_sizes_metadata() + { + // Create only thumbnail file (not testfile.png) so the restore writes testfile.png fresh. + $this->wp->createImage( 5000, '2026/04', 'testfile-150x150.png' ); + $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' ); + + $this->wp->stub( 'wp_generate_attachment_metadata', function ( $id, $file ) { + return array( + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + } ); + + $wp_metadata = array( + 'file' => '2026/04/testfile.png', + 'sizes' => array( + 'thumbnail' => array( 'file' => 'testfile-150x150.png', 'width' => 150, 'height' => 150 ), + ), + ); + $tiny_metadata = array( + Tiny_Image::ORIGINAL => array( 'input' => array( 'size' => 37857 ), 'output' => array( 'size' => 30000 ) ), + 'thumbnail' => array( 'input' => array( 'size' => 5000 ), 'output' => array( 'size' => 4000 ) ), + ); + $mock_settings = $this->createMock( Tiny_Settings::class ); + $mock_settings->method( 'get_sizes' )->willReturn( array() ); + $mock_settings->method( 'get_active_tinify_sizes' )->willReturn( array() ); + $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, $tiny_metadata ); + + $tiny_image->restore_backup(); + + assertEquals( array(), $tiny_image->get_image_size( Tiny_Image::ORIGINAL )->meta, 'expected original size metadata to be cleared' ); + assertEquals( array(), $tiny_image->get_image_size( 'thumbnail' )->meta, 'expected thumbnail size metadata to be cleared' ); + } + + public function test_clean_attachment_deletes_backup_file() + { + $this->wp->createImage( 37857, '2026/04', 'testfile.png' ); + $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' ); + $backup_path = $this->vfs->url() . '/wp-content/uploads/tinify_backup/2026/04/testfile.png'; + + $this->wp->stub( 'wp_get_attachment_metadata', function ( $i ) { + return array( + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + } ); + + $tiny_plugin = new Tiny_Plugin(); + $ref = new \ReflectionClass( $tiny_plugin ); + $settings_prop = $ref->getProperty( 'settings' ); + $settings_prop->setAccessible( true ); + $mock_settings = $this->createMock( Tiny_Settings::class ); + $settings_prop->setValue( $tiny_plugin, $mock_settings ); + + assertTrue( file_exists( $backup_path ), 'expected backup to exist before clean_attachment' ); + $tiny_plugin->clean_attachment( 1 ); + assertFalse( file_exists( $backup_path ), 'expected backup to be deleted after clean_attachment' ); + } + + public function test_ajax_init_adds_restore_backup_action() + { + $tiny_plugin = new Tiny_Plugin(); + $tiny_plugin->ajax_init(); + + WordPressStubs::assertHook( 'wp_ajax_tiny_restore_backup', array( $tiny_plugin, 'restore_backup_image' ) ); + } } diff --git a/test/wp-includes-for-tests/file.php b/test/wp-includes-for-tests/file.php index c5ea20a6..ab529fee 100644 --- a/test/wp-includes-for-tests/file.php +++ b/test/wp-includes-for-tests/file.php @@ -15,7 +15,14 @@ public function copy( $src, $dest, $overwrite = false, $mode = false ) { if ( ! $overwrite && file_exists( $dest ) ) { return false; } - return copy( $src, $dest ); + // Use file_get_contents + file_put_contents instead of copy() so that + // vfsStream files backed by LargeFileContent (read-only virtual content) + // can be overwritten in tests. + $content = file_get_contents( $src ); + if ( false === $content ) { + return false; + } + return file_put_contents( $dest, $content ) !== false; } public function get_contents( $path ) { |
|---|