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 @@
-
- get_name() ) );
- ?>
-
|
@@ -244,7 +244,7 @@
+
+
diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php
index 41972e61..f29a4ddc 100644
--- a/test/helpers/wordpress.php
+++ b/test/helpers/wordpress.php
@@ -92,6 +92,9 @@ 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_create_image_subsizes');
+ $this->addMethod('wp_update_attachment_metadata');
$this->addMethod('is_admin');
$this->addMethod('is_customize_preview');
$this->addMethod('is_plugin_active');
@@ -305,14 +308,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/integration/backup.spec.ts b/test/integration/backup.spec.ts
new file mode 100644
index 00000000..de828786
--- /dev/null
+++ b/test/integration/backup.spec.ts
@@ -0,0 +1,87 @@
+import { Page, expect, test } from '@playwright/test';
+import fs from 'fs/promises';
+import path from 'path';
+import { enableCompressionSizes, setAPIKey, setBackupEnabled, setCompressionTiming, setConversionSettings, setOriginalImage, uploadMedia } from './utils';
+
+test.describe.configure({ mode: 'serial' });
+
+let page: Page;
+
+function viewImage(page: Page, attachmentID: string) {
+ return page.goto(`/wp-admin/post.php?post=${attachmentID}&action=edit`);
+}
+
+test.describe('backup and restore', () => {
+ test.beforeAll(async ({ browser }) => {
+ page = await browser.newPage();
+ });
+
+ test.beforeEach(async () => {
+ await setAPIKey(page, 'JPG123');
+ await setCompressionTiming(page, 'auto');
+ await enableCompressionSizes(page, ['0']);
+ await setBackupEnabled(page, true);
+ // Resizing or converting makes the mock return a different image, which
+ // would break the comparisons against output-example.jpg below.
+ await setOriginalImage(page, { resize: false, preserveDate: false, preserveCopyright: false, preserveGPS: false });
+ await setConversionSettings(page, { convert: false });
+ });
+
+ test('enabling backup and compressing creates a backup of the original image', async () => {
+ const original = await fs.readFile(path.join(__dirname, '../fixtures/input-example.jpg'));
+
+ const { attachmentID } = await uploadMedia(page, 'input-example.jpg');
+
+ await viewImage(page, attachmentID);
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByText('1 size compressed')).toBeVisible();
+
+ await page.getByRole('link', { name: 'Details' }).click({ force: true });
+ await page.waitForSelector('#TB_overlay');
+
+ const backupLink = page.getByRole('link', { name: 'View uncompressed file' });
+ await expect(backupLink).toBeVisible();
+
+ const backupURL = await backupLink.getAttribute('href');
+ if (!backupURL) {
+ throw new Error('backup link has no href');
+ }
+
+ const response = await page.request.get(backupURL);
+ expect(response.ok()).toBeTruthy();
+
+ const backupContent = await response.body();
+ expect(backupContent.equals(original)).toBeTruthy();
+ });
+
+ test('restoring the backup replaces the compressed files with the original', async () => {
+ const uncompressed = await fs.readFile(path.join(__dirname, '../fixtures/input-example.jpg'));
+ const compressed = await fs.readFile(path.join(__dirname, '../mock-tinypng-webservice/output-example.jpg'));
+
+ const { attachmentID, imageURL } = await uploadMedia(page, 'input-example.jpg');
+
+ await viewImage(page, attachmentID);
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByText('1 size compressed')).toBeVisible();
+
+ // Sanity check: compression should have replaced the file on disk with a
+ // different (compressed) version, otherwise the restore assertion below
+ // would be meaningless.
+ const compressedContent = await (await page.request.get(imageURL)).body();
+ expect(compressedContent.equals(compressed)).toBeTruthy();
+
+ await page.getByRole('link', { name: 'Details' }).click({ force: true });
+ await page.waitForSelector('#TB_overlay');
+
+ await page.getByRole('link', { name: 'Restore Backup' }).click();
+ await expect(page.locator('dialog.tiny-dialog[open]')).toBeVisible();
+ await page.getByRole('button', { name: 'Restore' }).click();
+
+ await expect(page.getByText('1 size to be compressed')).toBeVisible();
+
+ await page.reload();
+
+ const restoredContent = await (await page.request.get(imageURL)).body();
+ expect(restoredContent.equals(uncompressed)).toBeTruthy();
+ });
+});
diff --git a/test/integration/utils.ts b/test/integration/utils.ts
index 88260d7e..3e5d1926 100644
--- a/test/integration/utils.ts
+++ b/test/integration/utils.ts
@@ -104,6 +104,18 @@ export async function enableCompressionSizes(page: Page, sizes: DefaultSizes[],
await page.locator('#submit').click();
}
+export async function setBackupEnabled(page: Page, enabled: boolean) {
+ await page.goto('/wp-admin/options-general.php?page=tinify');
+
+ if (enabled) {
+ await page.locator('#tinypng_backup').check({ force: true });
+ } else {
+ await page.locator('#tinypng_backup').uncheck({ force: true });
+ }
+
+ await page.locator('#submit').click();
+}
+
type OriginalImageSettings = {
resize: boolean;
width?: number;
diff --git a/test/unit/TinyPluginBackupTest.php b/test/unit/TinyPluginBackupTest.php
index 5e3a28b4..f066b157 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,130 @@ 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_create_image_subsizes', 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()
+ {
+ $this->wp->createImage( 65400, '2026/04', 'testfile-150x150.png' );
+ $this->wp->createImage( 123000, 'tinify_backup/2026/04', 'testfile.png' );
+
+ $this->wp->stub( 'wp_create_image_subsizes', function ( $id, $file ) {
+ return array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(
+ 'thumbnail' => array(
+ 'file' => '2026/04/testfile-150x150.png',
+ )
+ ),
+ );
+ } );
+
+ $wp_metadata = array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(
+ 'thumbnail' => array( 'file' => 'testfile-150x150.png', 'width' => 150, 'height' => 150 ),
+ ),
+ );
+ $mock_settings = $this->createMock( Tiny_Settings::class );
+ $mock_settings->method( 'get_sizes' )->willReturn( array(
+ Tiny_Image::ORIGINAL,
+ 'thumbnail',
+ ) );
+ $mock_settings->method( 'get_active_tinify_sizes' )->willReturn( array(
+ Tiny_Image::ORIGINAL,
+ 'thumbnail',
+ ) );
+ $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 ) ),
+ );
+ $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, $tiny_metadata );
+
+ assertEquals($tiny_image->get_image_size( 'thumbnail' )->filesize(), 65400, 'thumbnail before restoring');
+
+ // wp_generate_attachment_metadata will regenerate new thumbnails, mock this by creating
+ // a new thumbnail on vfs
+ // https://developer.wordpress.org/reference/functions/wp_generate_attachment_metadata/
+ $this->wp->createImage( 123654, '2026/04', 'testfile-150x150.png' );
+
+ $tiny_image->restore_backup();
+
+ assertEquals($tiny_image->get_image_size( 'thumbnail' )->filesize(), 123654, 'thumbnail before restoring');
+ }
+
+ 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 ) {