Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ A: You can upgrade to a paid account by adding your *Payment details* on your [a
A: When the conversion feature is enabled (to convert images to AVIF or WebP), each image will use double the number of credits: one for compression and one for format conversion.

== Changelog ==
= 3.7.0 =
* chore: migrated meta key from `tiny_compress_images` to `_tiny_compress_images`
Comment thread
tijmenbruggeman marked this conversation as resolved.

= 3.6.14 =
* fix: added check for valid path before deleting converted image
* fix: use hook uninstall_plugin instead of uninstall.php to prevent dependency deletion
Expand Down
144 changes: 144 additions & 0 deletions src/class-tiny-migrate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php
/*
* Tiny Compress Images - WordPress plugin.
* Copyright (C) 2015-2026 Tinify B.V.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 of the License, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc., 51
* Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/

/**
* Handles sequential database migrations for the TinyPNG plugin.
*
* Each migration method targets a specific version and is only executed
* once per site, tracked via the `DB_VERSION_OPTION` constant.
*
* @since 3.7.0
*/
class Tiny_Migrate {

/**
* The current database schema version.
*
* Increment this integer by 1 each time a new migration is added.
*
* @since 3.7.0
* @var int
*/
const DB_VERSION = 1;

/**
* WordPress option key used to track the applied database version.
*
* @since 3.7.0
* @var string
*/
const DB_VERSION_OPTION = 'tinypng_db_version';
Comment thread
tijmenbruggeman marked this conversation as resolved.

/**
* When migration fails, will pause migration for an hour
* when the key exists in memory
*
* @since 3.7.0
* @var string
*/
const MIGRATION_BACKOFF_KEY = 'tinypng_migration_backoff';

/**
* Returns an ordered map of migrations keyed by version number.
*
* Each entry maps a version integer to a callable that performs the
* corresponding migration. Add new entries in ascending version order.
* Increment `DB_VERSION` when adding a new migration.
*
* @since 3.7.0
*
* @return array<int, callable> Ordered map of version to migration callable.
*/
private static function migrations() {
return array(
1 => array( self::class, 'migrate_meta_key_to_private' ),
);
}

/**
* Runs all pending migrations in version order.
*
* Compares the stored database version against each known migration
* and executes any that have not yet been applied. Updates the stored
* version upon completion.
*
* @since 3.7.0
*
* @return void
*/
public static function run() {
$stored_version = (int) get_option( self::DB_VERSION_OPTION, 0 );

if ( $stored_version >= self::DB_VERSION ) {
return;
}

foreach ( self::migrations() as $version => $migration ) {
if ( $stored_version >= $version ) {
continue;
}

if ( get_transient( self::MIGRATION_BACKOFF_KEY ) ) {
// transient key to hold migrations exists so exit early
return;
}

if ( ! call_user_func( $migration ) ) {
set_transient( self::MIGRATION_BACKOFF_KEY, 1, HOUR_IN_SECONDS );
return;
}
}

update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
}
Comment thread
tijmenbruggeman marked this conversation as resolved.

/**
* Migrates the tiny meta key from public to private.
*
* Renames all `tiny_compress_images` post meta entries to
* `_tiny_compress_images`.
*
* @since 3.7.0
*
* @return bool True on success or when there is nothing to migrate, false on DB error.
*/
private static function migrate_meta_key_to_private() {
global $wpdb;

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->update(
$wpdb->postmeta,
array( 'meta_key' => '_tiny_compress_images' ),
array( 'meta_key' => 'tiny_compress_images' ),
array( '%s' ),
array( '%s' )
);

if ( false === $result ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Tinify: failed to migrate meta key. DB error: ' . $wpdb->last_error );
return false;
}
Comment thread
tijmenbruggeman marked this conversation as resolved.

// A return value of 0 means there was nothing to migrate, which is valid
// for fresh installs or databases that were already migrated.
return false !== $result;
}
}
2 changes: 1 addition & 1 deletion src/config/class-tiny-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ class Tiny_Config {
const SHRINK_URL = 'https://api.tinify.com/shrink';
const KEYS_URL = 'https://api.tinify.com/keys';
const MONTHLY_FREE_COMPRESSIONS = 500;
const META_KEY = 'tiny_compress_images';
const META_KEY = '_tiny_compress_images';
}
26 changes: 26 additions & 0 deletions test/helpers/wordpress.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

define('ABSPATH', dirname(__FILE__) . '/../');
define('WPINC', 'wp-includes-for-tests');
define('HOUR_IN_SECONDS', 3600);
require_once dirname(__FILE__) . '/../' . WPINC . '/file.php';

use org\bovigo\vfs\vfsStream;
Expand Down Expand Up @@ -53,9 +54,12 @@ class WordPressStubs
private $admin_initFunctions;
private $options;
private $metadata;
private $transients;
private $calls;
private $stubs;
private $filters;
public $postmeta = 'wp_postmeta';
public $last_error = '';

public function __construct($vfs)
{
Expand Down Expand Up @@ -98,6 +102,10 @@ public function __construct($vfs)
$this->addMethod('get_locale');
$this->addMethod('wp_timezone_string');
$this->addMethod('update_option');
$this->addMethod('update');
$this->addMethod('get_transient');
$this->addMethod('set_transient');
$this->addMethod('delete_transient');
$this->addMethod('check_ajax_referer');
$this->addMethod('wp_json_encode');
$this->addMethod('wp_send_json_error');
Expand All @@ -122,6 +130,7 @@ public function defaults()
$this->admin_initFunctions = array();
$this->options = new WordPressOptions();
$this->metadata = array();
$this->transients = array();
$this->filters = array();
$GLOBALS['_wp_additional_image_sizes'] = array();
}
Expand Down Expand Up @@ -185,6 +194,18 @@ public function call($method, $args)
}
if ('translate' === $method) {
return $args[0];
} elseif ('get_transient' === $method) {
$key = isset($args[0]) ? $args[0] : '';
return isset($this->transients[$key]) ? $this->transients[$key] : false;
} elseif ('set_transient' === $method) {
$key = isset($args[0]) ? $args[0] : '';
$value = isset($args[1]) ? $args[1] : '';
$this->transients[$key] = $value;
return true;
} elseif ('delete_transient' === $method) {
$key = isset($args[0]) ? $args[0] : '';
unset($this->transients[$key]);
return true;
} elseif ('get_option' === $method) {
return call_user_func_array(array($this->options, 'get'), $args);
} elseif ('get_post_meta' === $method) {
Expand Down Expand Up @@ -224,6 +245,11 @@ public function addOption($key, $value)
$this->options->set($key, $value);
}

public function addTransient($key, $value)
{
$this->transients[$key] = $value;
}

public function addImageSize($size, $values)
{
$GLOBALS['_wp_additional_image_sizes'][$size] = $values;
Expand Down
2 changes: 1 addition & 1 deletion test/unit/TinyImageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public function set_up() {
}

public function test_tiny_post_meta_key_may_never_change() {
$this->assertEquals( '61b16225f107e6f0a836bf19d47aa0fd912f8925', sha1( Tiny_Config::META_KEY ) );
$this->assertEquals( '438fc52ce17b9aedf0cf70dea52d5551affba59a', sha1( Tiny_Config::META_KEY ) );
}

public function test_update_wp_metadata_should_not_update_with_no_resized_original() {
Expand Down
101 changes: 101 additions & 0 deletions test/unit/TinyMigrateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

require_once dirname(__FILE__) . '/TinyTestCase.php';
require_once dirname(__FILE__) . '/../../src/class-tiny-migrate.php';

class Tiny_Migrate_Test extends Tiny_TestCase
{

public function set_up()
{
parent::set_up();
// migration test logs error in stdout so swallow error logs
ini_set('error_log', '/dev/null');
$this->wp->stub('update', function() {
return 1;
});
}

/**
* Helper to check if a specific option update occurred.
*/
private function assertOptionWasUpdated($option, $value)
{
$calls = $this->wp->getCalls('update_option');
foreach ($calls as $call) {
if (isset($call[0], $call[1]) && $call[0] === $option && $call[1] === $value) {
return $this->assertTrue(true);
}
}
$this->fail("Failed asserting that option '$option' was updated to '$value'.");
}

public function test_run_skips_migration_when_db_version_is_current()
{
$this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION);

Tiny_Migrate::run();

$this->assertCount(0, $this->wp->getCalls('update'), 'Should not touch DB if version matches.');
}

public function test_run_performs_migration_and_updates_version()
{
Tiny_Migrate::run();

$update_calls = $this->wp->getCalls('update');
$this->assertCount(1, $update_calls);

list($table, $data, $where) = $update_calls[0];

$this->assertEquals('wp_postmeta', $table);
$this->assertEquals(array('meta_key' => '_tiny_compress_images'), $data);
$this->assertEquals(array('meta_key' => 'tiny_compress_images'), $where);

$this->assertOptionWasUpdated(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION);
}

Comment thread
tijmenbruggeman marked this conversation as resolved.
public function test_run_does_not_update_db_version_when_migration_fails()
{
$this->wp->stub('update', function() { return false; });

Tiny_Migrate::run();

$option_calls = $this->wp->getCalls('update_option');
$version_updates = array_filter($option_calls, function($call) { return $call[0] === Tiny_Migrate::DB_VERSION_OPTION; });

$this->assertEmpty($version_updates, 'Should not update DB version when migration fails.');
}

public function test_run_does_not_update_option_if_unnecessary()
{
$this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION);

Tiny_Migrate::run();

$this->assertEmpty($this->wp->getCalls('update_option'), 'Should not call update_option at all when version is already current.');
}

public function test_run_sets_backoff_transient_when_migration_fails()
{
$this->wp->stub('update', function() { return false; });

Tiny_Migrate::run();

$set_transient_calls = $this->wp->getCalls('set_transient');
$this->assertCount(1, $set_transient_calls, 'A backoff transient should be set after a failed migration.');
$this->assertEquals(Tiny_Migrate::MIGRATION_BACKOFF_KEY, $set_transient_calls[0][0]);
$this->assertEquals(HOUR_IN_SECONDS, $set_transient_calls[0][2]);
}

public function test_run_skips_migration_when_backoff_transient_is_set()
{
$this->wp->stub('get_transient', function($key) {
return Tiny_Migrate::MIGRATION_BACKOFF_KEY === $key ? 1 : false;
});

Tiny_Migrate::run();

$this->assertCount(0, $this->wp->getCalls('update'), 'DB update should not be attempted during the backoff period.');
}
}
3 changes: 3 additions & 0 deletions tiny-compress-images.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

require dirname( __FILE__ ) . '/src/config/class-tiny-config.php';
require dirname( __FILE__ ) . '/src/class-tiny-migrate.php';
require dirname( __FILE__ ) . '/src/class-tiny-helpers.php';
require dirname( __FILE__ ) . '/src/class-tiny-php.php';
require dirname( __FILE__ ) . '/src/class-tiny-wp-base.php';
Expand Down Expand Up @@ -37,6 +38,8 @@
require dirname( __FILE__ ) . '/src/class-tiny-compress-fopen.php';
}

add_action( 'plugins_loaded', array( 'Tiny_Migrate', 'run' ) );

$tiny_plugin = new Tiny_Plugin();

register_uninstall_hook(
Expand Down
Loading