Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion bin/run-mocks
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

port="$1"

docker compose -f config/mocks.docker-compose.yml up -d
docker compose -f config/mocks.docker-compose.yml up -d --wait

mv src/vendor/tinify/Tinify/Client.php src/vendor/tinify/Tinify/Client.php.bak
cp test/fixtures/Client.php src/vendor/tinify/Tinify/Client.php
Expand Down
2 changes: 1 addition & 1 deletion src/class-tiny-compress.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 39 additions & 2 deletions src/class-tiny-image.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,9 @@ 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 );
$backup_created = $this->create_backup( $size_name, $size->filename );
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Backup creation is triggered for every unprocessed size (including sizes that are only being processed for conversion). This can create/overwrite the .bak file from an already-compressed original, which defeats the goal of backing up the uncompressed upload. Consider only creating the backup when the original is actually uncompressed (e.g., gate on the Tiny_Image_Size::uncompressed() state) and avoid backing up during conversion-only runs.

Suggested change
$backup_created = $this->create_backup( $size_name, $size->filename );
$backup_created = false;
if ( method_exists( $size, 'uncompressed' ) && $size->uncompressed() ) {
$backup_created = $this->create_backup( $size_name, $size->filename );
}

Copilot uses AI. Check for mistakes.
$resize = $this->settings->get_resize_options( $size_name );
$preserve = $this->settings->get_preserve_options( $size_name );
Tiny_Logger::debug(
'compress size',
array(
Expand All @@ -238,6 +239,7 @@ public function compress() {
'has_been_compressed' => $size->has_been_compressed(),
'filesize' => $size->filesize(),
'mimetype' => $size->mimetype(),
'backup' => $backup_created,
)
);
try {
Expand Down Expand Up @@ -605,4 +607,39 @@ public function mark_as_compressed() {

$this->update_tiny_post_meta();
}

/**
* creates a backup of the image as <originalfile>.bak.<extension>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To not interfere with existing plugins and whatever watches the file upload folder. Maybe write the files to another folder?

e.g

TinifyBackups/{original_path}

*
* @param string $size_name name of the size
* @param string $filepath path to file that needs backup
* @return bool true when backup is created
*/
private function create_backup( $size_name, $filepath ) {
if ( ! $this->needs_backup( $size_name ) ) {
return false;
}

$fileinfo = pathinfo( $filepath );
$backup_file = sprintf(
'%s%s.bak.%s',
trailingslashit( $fileinfo['dirname'] ),
$fileinfo['filename'],
$fileinfo['extension']
);

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

copy() will overwrite an existing backup file. If compress() is re-run later (e.g., conversion enabled after initial compression), this can silently replace the backup with a newer (possibly already-compressed) file. Consider skipping backup creation when the backup already exists (or use a unique/immutable naming strategy) so the first backup remains the original upload.

Suggested change
if ( file_exists( $backup_file ) ) {
// Backup already exists; keep the first backup (original upload) intact.
return true;
}

Copilot uses AI. Check for mistakes.
return copy( $filepath, $backup_file );
}

/**
* @param string $size_name name of the size
* @return bool true when backup needs to be created
*/
private function needs_backup( $size_name ) {
if ( ! self::is_original( $size_name ) ) {
return false;
}

return $this->settings->get_backup_enabled();
}
}
19 changes: 19 additions & 0 deletions src/class-tiny-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
* @return false|array<string> false if size is not original, otherwise array of preserved keys
Comment on lines +360 to +363
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Docblock for get_preserve_options has an invalid @param tag (missing variable name) and the return type description is a bit inconsistent. Use a standard form like "@param string $size_name" and consider "array|false" for the return type to match PHPDoc conventions.

Suggested change
* Retrieves the preserve options for the original image
*
* @param string - size name
* @return false|array<string> false if size is not original, otherwise array of preserved keys
* Retrieves the preserve options for the original image.
*
* @param string $size_name Size name.
* @return array<string>|false Array of preserved keys if size is original, or false otherwise.

Copilot uses AI. Check for mistakes.
*/
public function get_preserve_options( $size_name ) {
if ( ! Tiny_Image::is_original( $size_name ) ) {
return false;
Expand Down
22 changes: 22 additions & 0 deletions src/views/settings-original-image.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@
</div>
</div>

<p class="tiny-preserve">
<?php
$backup_enabled_id = self::get_prefixed_name( 'backup' );
$backup_enabled_name = self::get_prefixed_name( 'backup[enabled]' );
$backup_enabled = $this->get_backup_enabled();
?>
<input
type="checkbox"
id="<?php echo esc_attr( $backup_enabled_id ); ?>"
name="<?php echo esc_attr( $backup_enabled_name ); ?>"
value="on"
<?php checked( $backup_enabled ); ?> />
<label for="<?php echo esc_attr( $backup_enabled_id ); ?>">
<?php
esc_html_e(
'Create a backup of the uncompressed image',
'tiny-compress-images'
);
?>
</label>
</p>

<?php
$this->render_preserve_input(
'creation',
Expand Down
15 changes: 15 additions & 0 deletions test/helpers/wordpress.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => bytes to create, file will be named $name-$size.png
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

The createImages() docblock says generated files will be named "$name-$size.png", but the implementation uses the array key (size name) in the filename ("$name-$key.png"). Update the docblock wording so it matches what the helper actually writes.

Suggested change
* @param null|array $sizes Array of size => bytes to create, file will be named $name-$size.png
* @param null|array $sizes Array of size name (array key) => bytes to create; each file will be named "$name-<size name>.png"

Copilot uses AI. Check for mistakes.
* @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);
Expand Down Expand Up @@ -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 object containing metadata
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Docblock says "@return array object containing metadata", but getTestMetadata() returns a plain array. Consider updating the @return description to avoid confusion for test authors.

Suggested change
* @return array object containing metadata
* @return array metadata array

Copilot uses AI. Check for mistakes.
*/
public function getTestMetadata($path = '14/01', $name = 'test')
{
$metadata = array(
Expand Down
90 changes: 90 additions & 0 deletions test/unit/TinyImageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,94 @@ 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']);
}

public function test_creates_backup_of_original_image() {
$this->wp->addOption('tinypng_backup', array(
'enabled' => 'on',
));
$this->wp->addOption('tinypng_sizes', array(
Tiny_Image::ORIGINAL => 'on',
));
$this->wp->stub('get_post_mime_type', function () {
return 'image/png';
});

$input_dir = '26/03';
$input_name = 'testforbackup';
$this->wp->createImages(array(), 1000, $input_dir, $input_name);

$settings = new Tiny_Settings();
$mock_compressor = $this->createMock(Tiny_Compress::class);
$settings->set_compressor($mock_compressor);

$metadata = $this->wp->getTestMetadata($input_dir, $input_name);
Comment on lines +325 to +329
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

This test sets a Tiny_Compress mock but doesn’t stub compress_file(). The mock will return null, which means the code under test doesn’t exercise/validate handling of a real compressor response and could become fragile if Tiny_Image starts assuming an array response. Consider stubbing compress_file() to return a minimal valid details array and optionally asserting it was called once for the original size.

Copilot uses AI. Check for mistakes.
$tinyimg = new Tiny_Image($settings, 999, $metadata);
$tinyimg->compress();

$this->assertTrue(
file_exists( $this->vfs->url() . '/wp-content/uploads/' . $input_dir . '/' . $input_name . '.bak.png' ) ,
'backup of file should be created');
}

public function test_will_not_backup_other_sizes() {
$this->wp->addOption('tinypng_backup', array(
'enabled' => 'on',
));
$this->wp->addOption('tinypng_sizes', array(
'thumbnail' => 'on',
));
$this->wp->stub('get_post_mime_type', function () {
return 'image/png';
});

$input_dir = '26/03';
$input_name = 'testforbackup';
$this->wp->createImages(array(
'thumbnail' => 1000,
), 1000, $input_dir, $input_name);

$settings = new Tiny_Settings();
$mock_compressor = $this->createMock(Tiny_Compress::class);
$settings->set_compressor($mock_compressor);

$metadata = $this->wp->getTestMetadata($input_dir, $input_name);
Comment on lines +355 to +359
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Same as above: compress_file() isn’t stubbed on the Tiny_Compress mock, so the test doesn’t validate behavior with a real compressor response and may become brittle. Consider stubbing compress_file() to return a minimal valid details array (and assert call count) to keep the test meaningful.

Copilot uses AI. Check for mistakes.
$tinyimg = new Tiny_Image($settings, 999, $metadata);
$tinyimg->compress();

$this->assertFalse(
file_exists( $this->vfs->url() . '/wp-content/uploads/' . $input_dir . '/' . $input_name . '.bak.png' ) ,
'backup of original should not exist');

$this->assertFalse(
file_exists( $this->vfs->url() . '/wp-content/uploads/' . $input_dir . '/' . $input_name . '-thumbnail.bak.png' ) ,
'backup of thumbnail should not exist');
}

public function test_will_not_backup_when_disabled() {
$this->wp->addOption('tinypng_backup', array(
'enabled' => false,
));
$this->wp->addOption('tinypng_sizes', array(
Tiny_Image::ORIGINAL => 'on',
));
$this->wp->stub('get_post_mime_type', function () {
return 'image/png';
});

$input_dir = '26/03';
$input_name = 'testforbackup';
$this->wp->createImages(array(), 1000, $input_dir, $input_name);

$settings = new Tiny_Settings();
$mock_compressor = $this->createMock(Tiny_Compress::class);
$settings->set_compressor($mock_compressor);

$metadata = $this->wp->getTestMetadata($input_dir, $input_name);
$tinyimg = new Tiny_Image($settings, 999, $metadata);
Comment on lines +387 to +392
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Same as above: the Tiny_Compress mock isn’t configured to return a realistic value from compress_file(). Stubbing a minimal response would make this test less fragile and better aligned with actual runtime behavior.

Copilot uses AI. Check for mistakes.
$tinyimg->compress();

$this->assertFalse(
file_exists( $this->vfs->url() . '/wp-content/uploads/' . $input_dir . '/' . $input_name . '.bak.png' ) ,
'backup of original should not exist');
}
}
1 change: 1 addition & 0 deletions test/unit/TinySettingsAdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down
5 changes: 5 additions & 0 deletions test/unit/TinyTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ public static function client_supported() {
}

abstract class Tiny_TestCase extends TestCase {
/**
* WordPress stubs
*
* @var \WordPressStubs
*/
protected $wp;
protected $vfs;

Expand Down
Loading