diff --git a/docs/hooks/index.md b/docs/hooks/index.md index a978adfb..a1fe8c72 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -6,6 +6,8 @@ The plugin provides several hooks to let you extend or modify its behavior. - [updated_tiny_postmeta](updated_tiny_postmeta.md) — Triggered when tinify meta data has been updated - [tiny_image_after_compression](tiny_image_after_compression.md) — Triggered after successful optimization. +- [tiny_image_size_before_compression](tiny_image_size_before_compression.md) — Triggered before optimizing an image size. + ## Filters diff --git a/docs/hooks/tiny_image_before_compression.md b/docs/hooks/tiny_image_before_compression.md new file mode 100644 index 00000000..e6fa083a --- /dev/null +++ b/docs/hooks/tiny_image_before_compression.md @@ -0,0 +1,21 @@ +# tiny_image_size_before_compression + +Action that is done before compressing an single image size. + +**Location:** `src/class-tiny-image.php` +**Since:** 3.7.0 + +## Arguments + +1. `int $attachment_id` - The attachment ID. + +## Example + +```php +add_filter( + 'tiny_image_before_compression', + function ( $id ) { + // notify system of compression + } +); +``` diff --git a/src/class-tiny-compress.php b/src/class-tiny-compress.php index d17f55c9..236e8880 100644 --- a/src/class-tiny-compress.php +++ b/src/class-tiny-compress.php @@ -96,7 +96,7 @@ public function get_status() { /** * Compresses a single file * - * @param [type] $file + * @param string $file path to file * @param array $resize_opts * @param array $preserve_opts * @param array{ string } conversion options diff --git a/src/class-tiny-helpers.php b/src/class-tiny-helpers.php index 68b4688e..f2140d3c 100644 --- a/src/class-tiny-helpers.php +++ b/src/class-tiny-helpers.php @@ -171,4 +171,28 @@ public static function get_wp_filesystem() { return $wp_filesystem; } + + /** + * Polyfill for `str_starts_with()` function added in PHP 8.0. + * + * Performs a case-sensitive check indicating if + * the haystack begins with needle. + * + * @since 5.9.0 + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for in the `$haystack`. + * @return bool True if `$haystack` starts with `$needle`, otherwise false. + */ + public static function str_starts_with( $haystack, $needle ) { + if ( function_exists( 'str_starts_with' ) ) { + return str_starts_with( $haystack, $needle ); + } + + if ( '' === $needle ) { + return true; + } + + return 0 === strpos( $haystack, $needle ); + } } diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index a586f430..09e5ad66 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -196,6 +196,19 @@ public function compress() { 'name' => $this->name, ) ); + + /** + * Fires before an image is sent for compression. + * + * @since 3.6.8 + * + * @param int The attachment ID + */ + do_action( + 'tiny_image_before_compression', + $this->id + ); + if ( $this->settings->get_compressor() === null || ! $this->file_type_allowed() ) { return; } @@ -221,6 +234,7 @@ public function compress() { if ( ! $size->is_duplicate() ) { $size->add_tiny_meta_start(); $this->update_tiny_post_meta(); + $resize = $this->settings->get_resize_options( $size_name ); $preserve = $this->settings->get_preserve_options( $size_name ); Tiny_Logger::debug( diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index facff188..1249f687 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -67,6 +67,13 @@ public function init() { add_action( 'delete_attachment', $this->get_method( 'clean_attachment' ), 10, 2 ); + add_action( + 'tiny_image_before_compression', + $this->get_method( 'backup_original_image' ), + 10, + 1 + ); + load_plugin_textdomain( self::NAME, false, @@ -861,6 +868,64 @@ public function clean_attachment( $post_id ) { $tiny_image->delete_converted_image(); } + /** + * Creates a backup of an image size before compression. + * + * Hooked to the `tiny_image_before_compression` action. Only creates + * a backup for the original image size when the backup setting is enabled. + * The backup is stored under {upload_dir}/tinify_backup/, preserving the + * original path structure relative to the uploads base directory. + * + * relative path conversion from "_wp_relative_upload_path". + * + * @since 3.6.8 + * + * @param int $attachment_id The ID of the attachment + * @return bool return true on backup created + */ + public function backup_original_image( $attachment_id ) { + if ( ! $this->settings->get_backup_enabled() ) { + return false; + } + + $tiny_image = new Tiny_Image( $this->settings, $attachment_id ); + $original_image = $tiny_image->get_image_size(); + + $file_path = $original_image->filename; + $upload_dir = wp_upload_dir(); + if ( Tiny_Helpers::str_starts_with( $file_path, $upload_dir['basedir'] ) ) { + $file_path = str_replace( $upload_dir['basedir'], '', $original_image->filename ); + $file_path = ltrim( $file_path, '/' ); + } + + $upload_base = trailingslashit( $upload_dir['basedir'] ); + $backup_file = $upload_base . 'tinify_backup/' . $file_path; + + if ( file_exists( $backup_file ) ) { + return false; + } + + $backup_dir = dirname( $backup_file ); + + if ( ! wp_mkdir_p( $backup_dir ) ) { + return false; + } + + return copy( $original_image->filename, $backup_file ); + } + + public static function request_review() { + $review_url = + 'https://wordpress.org/support/plugin/tiny-compress-images/reviews/#new-post'; + $review_block = esc_html__( 'Enjoying TinyPNG?', 'tiny-compress-images' ); + $review_block .= ' '; + $review_block .= sprintf( + '%s', + esc_url( $review_url ), + esc_html__( 'Write a review', 'tiny-compress-images' ) + ); + return $review_block; + } public function mark_image_as_compressed() { $response = $this->validate_ajax_attachment_request(); diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index d19e8765..11eec868 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -123,6 +123,9 @@ public function admin_init() { $field = self::get_prefixed_name( 'resize_original' ); register_setting( 'tinify', $field ); + $field = self::get_prefixed_name( 'backup' ); + register_setting( 'tinify', $field ); + $field = self::get_prefixed_name( 'preserve_data' ); register_setting( 'tinify', $field ); @@ -305,6 +308,16 @@ public function new_plugin_install() { return ! $compression_timing; } + public function get_backup_enabled() { + $sizes = $this->get_sizes(); + if ( ! $sizes[ Tiny_Image::ORIGINAL ]['tinify'] ) { + return false; + } + + $setting = get_option( self::get_prefixed_name( 'backup' ) ); + return isset( $setting['enabled'] ) && 'on' === $setting['enabled']; + } + public function get_resize_enabled() { /* This only applies if the original is being resized. */ $sizes = $this->get_sizes(); @@ -343,6 +356,12 @@ public function get_preserve_enabled( $name ) { return isset( $setting[ $name ] ) && 'on' === $setting[ $name ]; } + /** + * Retrieves the preserve options for the original image + * + * @param string $size_name Name of the size + * @return array|false false if size is not original, otherwise array of preserved keys + */ public function get_preserve_options( $size_name ) { if ( ! Tiny_Image::is_original( $size_name ) ) { return false; diff --git a/src/views/settings-original-image.php b/src/views/settings-original-image.php index 261dd8d9..872055a3 100644 --- a/src/views/settings-original-image.php +++ b/src/views/settings-original-image.php @@ -68,6 +68,28 @@ +

+ get_backup_enabled(); + ?> + /> + +

+ render_preserve_input( 'creation', diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index 45e2406d..d48bb58e 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -282,6 +282,14 @@ public function createImage($file_size, $path, $name) ->at($dir); } + /** + * Creates images on the virtual disk for testing + * @param null|array $sizes Array of size name (array key) => bytes to create; each file will be named "$name-.png" + * @param int $original_size Bytes of image + * @param string $path Path to image + * @param string $name Name of the image + * @return void + */ public function createImages($sizes = null, $original_size = 12345, $path = '14/01', $name = 'test') { vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs); @@ -309,6 +317,13 @@ public function createImagesFromJSON($virtual_images) } } + /** + * creates image meta data for testing + * + * @param string $path directory of the file in UPLOAD_DIR + * @param string $name name of the file without extension + * @return array metadata array + */ public function getTestMetadata($path = '14/01', $name = 'test') { $metadata = array( @@ -338,6 +353,7 @@ public function getTestMetadata($path = '14/01', $name = 'test') */ public static function assertHook($hookname, $expected_args = null) { + $found = self::findHook($hookname, $expected_args); $message = is_null($expected_args) @@ -366,7 +382,7 @@ public static function assertNotHook($hookname, $expected_args = null) private static function findHook($hookname, $expected_args = null) { - $hooks = array('add_action', 'add_filter'); + $hooks = array('add_action', 'add_filter', 'do_action', 'apply_filters'); $found = false; foreach ($hooks as $method) { @@ -425,7 +441,7 @@ public function current_time() */ public function wp_mkdir_p($dir) { - mkdir($dir, 0755, true); + return mkdir($dir, 0755, true) || is_dir($dir); } /** @@ -486,9 +502,7 @@ public function wp_timezone_string() * * @return void */ - public function update_option() - { - } + public function update_option() {} /** * Mocked function for https://developer.wordpress.org/reference/functions/check_ajax_referer/ @@ -560,7 +574,7 @@ public function esc_html($text) { return $text; } - + /** * Mocked function for https://developer.wordpress.org/reference/functions/insert_with_markers/ * @@ -570,16 +584,16 @@ public function insert_with_markers($filename, $marker, $insertion) { $content = file_exists($filename) ? file_get_contents($filename) : ''; $insertion = is_array($insertion) ? implode("\n", $insertion) : $insertion; - + $start = "# BEGIN {$marker}"; $end = "# END {$marker}"; - + $content = preg_replace('/' . preg_quote($start, '/') . '.*?' . preg_quote($end, '/') . '\s*/s', '', $content); - + if ($insertion) { $content = "{$start}\n{$insertion}\n{$end}\n" . ltrim($content); } - + return file_put_contents($filename, trim($content) . "\n") !== false; } } diff --git a/test/unit/TinyImageTest.php b/test/unit/TinyImageTest.php index f70f3b4a..03b8bd3e 100644 --- a/test/unit/TinyImageTest.php +++ b/test/unit/TinyImageTest.php @@ -306,4 +306,5 @@ public function test_conversion_same_mimetype() // second call should be only with image/webp because first call was a image/webp $this->assertEquals(array('image/webp'), $compress_calls[1]['convert_to']); } + } diff --git a/test/unit/TinyPluginTest.php b/test/unit/TinyPluginTest.php index f99cd7e2..471be945 100644 --- a/test/unit/TinyPluginTest.php +++ b/test/unit/TinyPluginTest.php @@ -3,7 +3,9 @@ require_once dirname(__FILE__) . '/TinyTestCase.php'; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\content\LargeFileContent; + +use function PHPUnit\Framework\assertFalse; +use function PHPUnit\Framework\assertTrue; class Tiny_Plugin_Test extends Tiny_TestCase { @@ -495,4 +497,103 @@ public function test_conversion_enabled_and_not_filtered() WordPressStubs::assertHook('template_redirect', array($tiny_picture, 'on_template_redirect')); } + + public function test_init_adds_backup_image_size_action() { + $tiny_plugin = new Tiny_Plugin(); + $tiny_plugin->init(); + + // assert that backup is hooked into `tiny_image_size_before_compression` + WordPressStubs::assertHook('tiny_image_before_compression', array($tiny_plugin, 'backup_original_image')); + } + + public function test_will_copy_original_file_on_backup() { + $this->wp->createImage( 37857, '2026/04', 'testfile.png' ); + $expected_backup = $this->vfs->url() . '/wp-content/uploads/tinify_backup/2026/04/testfile.png'; + + $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); + $mock_settings->method('get_backup_enabled')->willReturn(true); + $settings_prop->setValue($tiny_plugin, $mock_settings); + + $this->wp->stub('wp_get_attachment_metadata', function ($i) { + return array( + 'width' => 1256, + 'height' => 1256, + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + }); + + $backup_made = $tiny_plugin->backup_original_image(1); + + assertTrue($backup_made, 'expected backup to be made'); + assertTrue(file_exists($expected_backup), 'expected backup to be created'); + } + + public function test_no_backup_when_backup_exists() { + $this->wp->createImage( 37857, '2026/04', 'testfile.png' ); + $expected_backup = $this->vfs->url() . '/wp-content/uploads/tinify_backup/2026/04/testfile.png'; + + $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); + $mock_settings->method('get_backup_enabled')->willReturn(true); + $settings_prop->setValue($tiny_plugin, $mock_settings); + + $this->wp->stub('wp_get_attachment_metadata', function ($i) { + return array( + 'width' => 1256, + 'height' => 1256, + 'file' => '2026/04/testfile.png', + 'sizes' => array(), + ); + }); + + $this->wp->createImage( 37857, 'tinify_backup/2026/04', 'testfile.png' ); + + $backup_made = $tiny_plugin->backup_original_image(1); + + assertFalse($backup_made, 'expected backup not to be made'); + assertTrue(file_exists($expected_backup), 'expected backup to exist'); + } + + /** + * when the attachment file path contains the upload directory name as a path + * segment, the relative path must be extracted using only the leading basedir prefix + */ + public function test_backup_preserves_upload_dir_name_in_relative_path() { + $this->wp->createImage( 37857, 'wp-content/uploads/2026/04', 'testfile.png' ); + + $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); + $mock_settings->method('get_backup_enabled')->willReturn(true); + $settings_prop->setValue($tiny_plugin, $mock_settings); + + $this->wp->stub('wp_get_attachment_metadata', function ($i) { + return array( + 'width' => 1256, + 'height' => 1256, + 'file' => 'wp-content/uploads/2026/04/testfile.png', + 'sizes' => array(), + ); + }); + + $backup_made = $tiny_plugin->backup_original_image(1); + + assertTrue($backup_made, 'expected backup to be made'); + + $expected_backup = $this->vfs->url() . '/wp-content/uploads/tinify_backup/wp-content/uploads/2026/04/testfile.png'; + assertTrue(file_exists($expected_backup), 'expected backup at path preserving the upload dir segment'); + } } diff --git a/test/unit/TinySettingsAdminTest.php b/test/unit/TinySettingsAdminTest.php index 3de0d81d..3feb1b1c 100644 --- a/test/unit/TinySettingsAdminTest.php +++ b/test/unit/TinySettingsAdminTest.php @@ -18,6 +18,7 @@ public function test_admin_init_should_register_keys() { array( 'tinify', 'tinypng_compression_timing' ), array( 'tinify', 'tinypng_sizes' ), array( 'tinify', 'tinypng_resize_original' ), + array( 'tinify', 'tinypng_backup' ), array( 'tinify', 'tinypng_preserve_data' ), array( 'tinify', 'tinypng_convert_format' ), array( 'tinify', 'tinypng_logging_enabled' ), diff --git a/test/unit/TinyTestCase.php b/test/unit/TinyTestCase.php index 4838a404..c64f22d6 100644 --- a/test/unit/TinyTestCase.php +++ b/test/unit/TinyTestCase.php @@ -40,6 +40,11 @@ public static function client_supported() { } abstract class Tiny_TestCase extends TestCase { + /** + * WordPress stubs + * + * @var \WordPressStubs + */ protected $wp; protected $vfs;