Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* 🐞 Bug: Show an error message when the license deactivation request fails with a server or authentication error, instead of leaving the spinner running
* 🐞 Bug: Ignore repeat clicks of the Deactivate button while a request is in progress
* 🐞 Bug: Clearing a license key from the settings now updates the license status straight away
* 🐞 Bug: Delete all cached update information when a license key is deactivated/cleared from the settings page, or is rejected by the licensing server
* 🐞 Bug: Fix PHP fatal error when doing an update check and the licensing server is unavailable
* 🐞 Bug: Stop repeated update requests if the licensing server returns an empty response
* 💻 Developer: Add `GPDFAPI::get_pdf_filename( $entry_id, $pdf_id, $include_extension )` to get the filename of a PDF
Expand Down
37 changes: 35 additions & 2 deletions src/Helper/Helper_Abstract_Addon.php
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,22 @@ public function update_license_info( $license_info, $use_database = false ) {
$this->options->update_settings( $settings );
}

/**
* Whether incoming license info differs from what this add-on already holds
*
* The incoming key is compared against the raw `$license_key` property, not get_license_key(), which folds in the
* `GPDF_LICENSE_KEY` constant and so reads identically on both sides however the add-on's own key changed.
*
* @param array $license_info
*
* @return bool
*
* @since 6.16.0
*/
private function license_info_has_changed( $license_info ) {
return $license_info['license'] !== $this->license_key || $license_info['status'] !== $this->license_key_status;
}

/**
* Remove the license info and keys from the settings
*
Expand Down Expand Up @@ -1169,7 +1185,16 @@ public function maybe_auto_activate_license( $response, $addon, $use_database =
return;
}

$this->update_license_info( $addon->get_license_info(), $use_database );
$license_info = $addon->get_license_info();
$has_changed = $this->license_info_has_changed( $license_info );

$this->update_license_info( $license_info, $use_database );

/* Cached update info was fetched under the old key, so it holds no package for the new one. A hardcoded key
re-fires this action every few hours, so skip the flush when nothing actually moved. */
if ( $has_changed ) {
$this->flush_update_cache();
}

$this->license_auto_activated = true;
}
Expand Down Expand Up @@ -1251,7 +1276,14 @@ public function maybe_auto_deactivate_license( $response, $addon ) {
return;
}

$this->update_license_info( $addon->get_license_info(), true );
$license_info = $addon->get_license_info();
$has_changed = $this->license_info_has_changed( $license_info );

$this->update_license_info( $license_info, true );

if ( $has_changed ) {
$this->flush_update_cache();
}

$this->license_auto_deactivated = true;
}
Expand All @@ -1269,5 +1301,6 @@ public function flush_update_cache() {

$this->plugin_updater->delete_version_info_cache();
$this->plugin_updater->delete_transient_plugin_info();
$this->plugin_updater->delete_network_version_info_cache();
}
}
48 changes: 47 additions & 1 deletion src/Helper/Licensing/EDD_SL_Plugin_Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ class EDD_SL_Plugin_Updater {
/* The network-shared package outlives the per-site 3h check cache so a quiet licensed site can't let it lapse */
protected const NETWORK_CACHE_TTL = 3 * DAY_IN_SECONDS;

/*
* Drawn from Helper_Data::addon_license_responses(). `error` and `rate_limit` are deliberately absent — a failed or
* throttled request is not a verdict on the license, so it must not strip a package other sites depend on.
*/
protected const UNENTITLED_LICENSE_STATUSES = [
'expired',
'revoked',
'disabled',
'missing',
'invalid',
'site_inactive',
'item_name_mismatch',
'invalid_item_id',
'no_activations_left',
];

/**
* Class constructor.
*
Expand Down Expand Up @@ -735,11 +751,12 @@ public function set_version_info_cache( $value = '', $cache_key = '' ) {
/*
* Promote the package to a network option so any site on the Multisite can install the update. The store can
* return a package URL even when the license is inactive (that URL errors on access), so only an active license
* is allowed to share its package.
* is allowed to share its package. The promoting site is recorded so it alone can withdraw the package later.
*/
if ( is_multisite() && $this->is_license_active() && is_object( $value ) && ! empty( $value->package ) ) {
$network_data = $data;
$network_data['timeout'] = time() + self::NETWORK_CACHE_TTL;
$network_data['blog_id'] = get_current_blog_id();
update_site_option( $this->get_network_cache_key(), $network_data );
}
}
Expand All @@ -761,6 +778,35 @@ public function delete_version_info_cache( $cache_key = '' ) {
return delete_option( $cache_key );
}

/**
* Withdraw the package this site shared with the network, so a removed license stops backing network-wide downloads
*
* Ownership counts as much as the license state: a package promoted by another site belongs to a site that is still
* licensed, and must outlive this one losing its own.
*
* @return bool
*
* @since 6.16.0
*/
public function delete_network_version_info_cache() {
if ( ! is_multisite() ) {
return false;
}

$lost_license = empty( $this->api_data['license'] ) || in_array( $this->license_status, self::UNENTITLED_LICENSE_STATUSES, true );
if ( ! $lost_license ) {
return false;
}

$cache_key = $this->get_network_cache_key();
$cache = get_site_option( $cache_key );
if ( ! is_array( $cache ) || (int) ( $cache['blog_id'] ?? 0 ) !== get_current_blog_id() ) {
return false;
}

return delete_site_option( $cache_key );
}

/**
* Delete the cached update info without removing the entire update plugin data
*
Expand Down
6 changes: 6 additions & 0 deletions src/Model/Model_Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ public function maybe_active_licenses( $input ) {
]
);

/* Clearing the field removes the license as surely as the Deactivate button does — drop the cached
package. Every add-on posts an empty field on an unrelated save, so only flush when a key was set. */
if ( ! empty( $settings[ $option_key ] ) ) {
$addon->flush_update_cache();
}

continue;
}

Expand Down
4 changes: 2 additions & 2 deletions src/Model/Model_Uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ public function remove_plugin_options() {
/**
* Remove the network-shared license package cache stored in sitemeta on a Multisite
*
* The per-site caches removed by remove_plugin_options() live in each site's options table; the network cache
* (see EDD_SL_Plugin_Updater::get_network_cache_key()) is a single network option, so it's cleaned up once here.
* Every extension keeps its own entry (see EDD_SL_Plugin_Updater::get_network_cache_key()), but all are
* network-scoped rather than per-site, so one wildcard pass clears them without walking the site list.
*
* @since 6.16.0
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1065,4 +1065,91 @@ public function test_get_repo_api_data_does_not_borrow_expired_network_package()
$this->assertSame( '0.2', $result->new_version );
$this->assertEmpty( $result->package );
}

public function test_delete_network_version_info_cache_withdraws_own_promotion() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'Multisite tests only' );
}

delete_site_option( $this->class->get_network_cache_key() );
$this->class->set_license_status( 'valid' );

$licensed = new \stdClass();
$licensed->new_version = '0.2';
$licensed->package = 'https://store.com/download/licensed-123';
$this->class->set_version_info_cache( $licensed );

$cache = get_site_option( $this->class->get_network_cache_key() );
$this->assertSame( get_current_blog_id(), $cache['blog_id'] );

/* A failed or throttled check is not a verdict on the license, so the shared package stands */
foreach ( [ 'error', 'rate_limit' ] as $status ) {
$this->class->set_license_status( $status );
$this->assertFalse( $this->class->delete_network_version_info_cache(), $status );
}

/* Removing the key does withdraw it */
$this->class->set_license_key( '' );
$this->assertTrue( $this->class->delete_network_version_info_cache() );
$this->assertFalse( get_site_option( $this->class->get_network_cache_key() ) );
}

/**
* @dataProvider providerUnentitledLicenseStatus
*/
public function test_delete_network_version_info_cache_withdraws_on_store_rejection( $status ) {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'Multisite tests only' );
}

delete_site_option( $this->class->get_network_cache_key() );
$this->class->set_license_status( 'valid' );

$licensed = new \stdClass();
$licensed->new_version = '0.2';
$licensed->package = 'https://store.com/download/licensed-123';
$this->class->set_version_info_cache( $licensed );

/* The key stays populated on a rejection, so the status is the only signal the entitlement ended */
$this->class->set_license_status( $status );

$this->assertTrue( $this->class->delete_network_version_info_cache() );
$this->assertFalse( get_site_option( $this->class->get_network_cache_key() ) );
}

public function providerUnentitledLicenseStatus() {
return [
[ 'expired' ],
[ 'revoked' ],
[ 'disabled' ],
[ 'missing' ],
[ 'invalid' ],
[ 'site_inactive' ],
[ 'item_name_mismatch' ],
[ 'invalid_item_id' ],
[ 'no_activations_left' ],
];
}

public function test_delete_network_version_info_cache_keeps_another_sites_promotion() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'Multisite tests only' );
}

/* Another site promoted this package, so its license is still active and the package must survive */
$network = new \stdClass();
$network->new_version = '0.2';
$network->package = 'https://store.com/download/licensed-123';
update_site_option(
$this->class->get_network_cache_key(),
[ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( $network ), 'blog_id' => get_current_blog_id() + 1 ]
);

$this->class->set_license_key( '' );

$this->assertFalse( $this->class->delete_network_version_info_cache() );
$this->assertNotEmpty( get_site_option( $this->class->get_network_cache_key() ) );

delete_site_option( $this->class->get_network_cache_key() );
}
}
45 changes: 45 additions & 0 deletions tests/phpunit/unit-tests/Model/Test_Model_Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,51 @@ public function test_maybe_active_licenses_clears_in_memory_status_on_empty_key(
$this->assertSame( '', $this->addon->get_license_key() );
}

public function test_maybe_active_licenses_flushes_update_cache_on_empty_key() {
do_action( 'init' );

$options = \GPDFAPI::get_options_class();
$slug = $this->addon->get_slug();

$settings = $options->get_settings();
$settings[ "license_$slug" ] = 'old-key';
$options->update_settings( $settings );

$this->addon->update_license_info( [ 'license' => 'old-key', 'status' => 'active', 'message' => 'ok' ] );

$updater = $this->addon->get_plugin_updater();
update_option(
$updater->get_cache_key(),
[ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( (object) [ 'new_version' => '2.0' ] ) ]
);

$this->model->maybe_active_licenses( [ "license_$slug" => '' ] );

/* The cached update info was fetched with the now-removed key, so it must not outlive it */
$this->assertFalse( $updater->get_cached_version_info() );

delete_option( $updater->get_cache_key() );
}

public function test_maybe_active_licenses_skips_flush_for_unlicensed_addon() {
do_action( 'init' );

$slug = $this->addon->get_slug();
$updater = $this->addon->get_plugin_updater();

update_option(
$updater->get_cache_key(),
[ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( (object) [ 'new_version' => '2.0' ] ) ]
);

/* Every add-on posts an empty license field on an unrelated save; one that never had a key keeps its cache */
$this->model->maybe_active_licenses( [ "license_$slug" => '' ] );

$this->assertIsObject( $updater->get_cached_version_info() );

delete_option( $updater->get_cache_key() );
}

public function test_licensing_bulk_license_check_skips_malformed_and_unknown_items() {
do_action( 'init' );

Expand Down
Loading
Loading