Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6af270d
Set title of modal to image title
tijmenbruggeman Jul 23, 2026
86e8030
move backup func to image
tijmenbruggeman Jul 23, 2026
88bc995
show backup info and actions on details modal
tijmenbruggeman Jul 23, 2026
7a90ef7
tiny ui fix
tijmenbruggeman Jul 23, 2026
8fc5564
add ajax action for restoring
tijmenbruggeman Jul 24, 2026
38e0029
restore backup js
tijmenbruggeman Jul 24, 2026
39fb07b
add template
tijmenbruggeman Jul 24, 2026
2521b11
style: apply WordPress PHP coding standards
tijmenbruggeman Jul 24, 2026
834e024
delete backup when deleting attachment
tijmenbruggeman Jul 24, 2026
8b380e3
remove unused variables
tijmenbruggeman Jul 24, 2026
c8b54f5
handle error cases
tijmenbruggeman Jul 24, 2026
a3fb73a
test(backup): add restore and clean attachment tests
tijmenbruggeman Jul 24, 2026
befe6d5
refactor(backup): use invoker API for restore dialog
tijmenbruggeman Jul 25, 2026
de2e855
refactor(admin): async restoreBackup with spinner feedback
tijmenbruggeman Jul 26, 2026
a252aab
feat(plugin): add data-tiny-media-id to image elements
tijmenbruggeman Jul 26, 2026
c77f3ea
refactor(backup): improve restore dialog UX and markup structure
tijmenbruggeman Jul 26, 2026
2feb2f3
refactor(compress-details): show backup section only when compressed
tijmenbruggeman Jul 26, 2026
5b1193c
style: fix WPCS spacing in plugin and views
tijmenbruggeman Jul 26, 2026
498c6f1
git commit -m "fix(backup): show error in dialog on restore failure
tijmenbruggeman Jul 26, 2026
70b68bb
fix(admin): prevent Thickbox content duplication on restore
tijmenbruggeman Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions src/class-tiny-image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Comment on lines +720 to +772

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled Tiny_Helpers::get_wp_filesystem() exception on hot paths.

get_wp_filesystem() throws when WP_Filesystem() can't connect (e.g. environments without direct filesystem access, requiring FTP credentials):

if ( ! ( $wp_filesystem instanceof WP_Filesystem_Base ) ) {
    throw new Exception( 'Unable to initialize WordPress filesystem.' );
}

None of the new backup methods (create_backup, get_backup, restore_backup, delete_backup) catch this. get_backup() in particular runs on every admin media-list render for any compressed image (via compress-details-backup.phprender_media_column()), so on such environments the whole media list would fatal instead of degrading gracefully. create_backup() is invoked synchronously from the tiny_image_before_compression action, so the same failure would abort compression entirely.

Consider wrapping these calls in try/catch and failing soft (return false) instead of letting the exception propagate into page rendering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/class-tiny-image.php` around lines 720 - 772, Handle exceptions from
Tiny_Helpers::get_wp_filesystem() in all backup methods: create_backup(),
get_backup(), restore_backup(), and delete_backup(). Wrap each filesystem
initialization in try/catch and return false when initialization fails,
preserving normal behavior when it succeeds so media rendering and compression
degrade gracefully.


/**
* 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;
}
Comment on lines +774 to +825

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale $this->sizes after restore — renders incorrect details immediately after restoring.

After the backup is copied over and wp_generate_attachment_metadata() regenerates thumbnails, $this->wp_metadata is updated, but $this->sizes (used by get_image_sizes()/get_image_size()) is never rebuilt from the new metadata — only the .meta compression-stats field is cleared on the existing (potentially stale) Tiny_Image_Size objects. Any newly generated/removed thumbnail sizes, or filenames that changed as a result of the regenerated image, won't be reflected.

This directly affects Tiny_Plugin::restore_backup_image() (src/class-tiny-plugin.php:987-997), which immediately calls render_compress_details() on this same instance — the AJAX response the user sees right after clicking "Yes, Restore" can show a stale/incorrect size list until the page is reloaded.

Also, lines 798-802 re-implement the exact original-size lookup already provided by get_original_image() (lines 685-696) — worth reusing instead of duplicating.

🐛 Proposed fix
-		$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;
-		}
+		$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 = 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 );
+			$this->sizes = array();
+			$this->parse_wp_metadata();
 		}
 
 		return true;

Unit test verifies restore_backup() overwrites the original attachment file from the tinify_backup copy., but does not assert the sizes list is consistent afterwards, so this gap isn't currently caught.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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;
}
/**
* 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_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 = 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 );
$this->sizes = array();
$this->parse_wp_metadata();
}
return true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/class-tiny-image.php` around lines 774 - 825, Update restore_backup() to
reuse get_original_image() for the restored original image instead of
duplicating the ORIGINAL_UNSCALED/ORIGINAL lookup. After
wp_generate_attachment_metadata() succeeds, rebuild $this->sizes from the new
attachment metadata so get_image_sizes() and get_image_size() immediately
reflect regenerated, added, removed, or renamed thumbnails; retain the existing
metadata update and success behavior.


/**
* 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 );
}
}
76 changes: 43 additions & 33 deletions src/class-tiny-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 '<div class="tiny-ajax-container">';
echo '<div class="tiny-ajax-container" data-tiny-media-id="' . absint( $id ) . '">';
$this->render_compress_details( $tiny_image );
echo '</div>';
}
Expand All @@ -761,7 +766,8 @@ public function show_media_info() {
echo '<h4>';
esc_html_e( 'JPEG, PNG, & WebP optimization', 'tiny-compress-images' );
echo '</h4>';
echo '<div class="tiny-ajax-container">';
echo '<div class="tiny-ajax-container" data-tiny-media-id="';
echo absint( $post->ID ) . '">';
$this->render_compress_details( $tiny_image );
echo '</div>';
echo '</div>';
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -934,37 +941,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() {
Expand All @@ -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'] ) ) {
Expand Down
19 changes: 18 additions & 1 deletion src/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ input[type=number][name*="tinypng_resize_original"] {
}

.tiny-compression-details {
padding: 10px;
padding: 10px 0;
}

.tiny-compression-details table {
Expand Down Expand Up @@ -481,4 +481,21 @@ 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;
}
67 changes: 66 additions & 1 deletion src/js/admin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,69 @@
(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}"]`);

dialog.showModal();

dialog.addEventListener('cancel', async (e) => {
e.preventDefault();
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';
}
}
}, { once: true });
Comment on lines +22 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pressing Escape (or any dialog-close-request) triggers the destructive restore action.

The confirm ("Yes, Restore") button uses command="request-close". Per spec, that command fires a cancel event on the dialog — the same event that fires when the user presses the Escape key to dismiss the dialog. Since the only listener attached is on dialog's cancel event, a user pressing Escape to back out of the confirmation will inadvertently execute the restore (overwriting all compressed sizes), not cancel it.

Separately, a new one-time cancel listener is added on every click of "Restore Backup" (line 32), but it's never removed if the dialog is dismissed via the "Cancel" button (command="close", which does not fire cancel). Re-opening the same dialog (same DOM node, since only a successful restore replaces container.innerHTML) after a prior cancel therefore stacks multiple listeners — a subsequent confirm click would fire restoreBackup() once per accumulated listener.

Bind the confirm action to the button's own click instead of overloading the shared cancel event, and don't rely on { once: true } alone to prevent duplicate handlers.

🐛 Proposed fix
     dialog.showModal();
 
-    dialog.addEventListener('cancel', async (e) => {
-      e.preventDefault();
-      const spinner = dialog.querySelector('.spinner');
+    const confirmButton = dialog.querySelector('button[value="submit"]');
+    if (!confirmButton) {
+      return;
+    }
+
+    const onConfirm = 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';
         }
       }
-    }, { once: true });
+    };
+
+    confirmButton.addEventListener('click', onConfirm, { once: true });
   });

This also requires dropping command="request-close"/commandfor from the confirm button in src/views/compress-details-backup.php (lines 39-41) in favor of a plain type="button", so it no longer triggers the dialog's cancel event at all.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 48-48: Avoid assigning untrusted data to innerHTML/outerHTML or document.write
Context: container.innerHTML = result
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(inner-outer-html)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/admin.js` around lines 22 - 64, Move the restore workflow from the
dialog’s cancel listener to the confirm button’s click handler, so Escape only
dismisses the dialog and never calls restoreBackup. In the relevant admin.js
handler, explicitly remove any previously registered confirm handler before
adding or assigning the new one, rather than relying on { once: true }, so
cancelling and reopening cannot accumulate listeners. Update the confirm button
markup to use type="button" and remove command="request-close" and commandfor.

});

function downloadDiagnostics() {
try {
jQuery('#download-diagnostics-spinner').show();
Expand Down
Loading
Loading