You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
14 KiB
411 lines
14 KiB
<?php
|
|
/**
|
|
* @package Freemius
|
|
* @copyright Copyright (c) 2015, Freemius, Inc.
|
|
* @license https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License Version 3
|
|
* @since 2.6.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
interface FS_I_Garbage_Collector {
|
|
function clean();
|
|
}
|
|
|
|
class FS_Product_Garbage_Collector implements FS_I_Garbage_Collector {
|
|
/**
|
|
* @var FS_Options
|
|
*/
|
|
private $_accounts;
|
|
|
|
/**
|
|
* @var string[]
|
|
*/
|
|
private $_options_names;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
private $_type;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
private $_plural_type;
|
|
|
|
/**
|
|
* @var array<string, int> Map of product slugs to their last load timestamp, only for products that are not active.
|
|
*/
|
|
private $_gc_timestamp;
|
|
|
|
/**
|
|
* @var array<string, array<string, mixed>> Map of product slugs to their data, as stored by the primary storage of `Freemius` class.
|
|
*/
|
|
private $_storage_data;
|
|
|
|
function __construct( FS_Options $_accounts, $option_names, $type ) {
|
|
$this->_accounts = $_accounts;
|
|
$this->_options_names = $option_names;
|
|
$this->_type = $type;
|
|
$this->_plural_type = ( $type . 's' );
|
|
}
|
|
|
|
function clean() {
|
|
$this->_gc_timestamp = $this->_accounts->get_option( 'gc_timestamp', array() );
|
|
$this->_storage_data = $this->_accounts->get_option( $this->_type . '_data', array() );
|
|
|
|
$options = $this->load_options();
|
|
$has_updated_option = false;
|
|
|
|
$products_to_clean = $this->get_products_to_clean();
|
|
|
|
foreach( $products_to_clean as $product ) {
|
|
$slug = $product->slug;
|
|
|
|
// Clear the product's data.
|
|
foreach( $options as $option_name => $option ) {
|
|
$updated = false;
|
|
|
|
/**
|
|
* We expect to deal with only array like options here.
|
|
* @todo - Refactor this to create dedicated GC classes for every option, then we can make the code mode predictable.
|
|
* For example, depending on data integrity of `plugins` we can still miss something entirely in the `plugin_data` or vice-versa.
|
|
* A better algorithm is to iterate over all options individually in separate classes and check against primary storage to see if those can be garbage collected.
|
|
* But given the chance of data integrity issue is very low, we let this run for now and gather feedback.
|
|
*/
|
|
if ( ! is_array( $option ) ) {
|
|
continue;
|
|
}
|
|
|
|
if ( array_key_exists( $slug, $option ) ) {
|
|
unset( $option[ $slug ] );
|
|
$updated = true;
|
|
} else if ( array_key_exists( "{$slug}:{$this->_type}", $option ) ) { /* admin_notices */
|
|
unset( $option[ "{$slug}:{$this->_type}" ] );
|
|
$updated = true;
|
|
} else if ( isset( $product->id ) && array_key_exists( $product->id, $option ) ) { /* all_licenses */
|
|
unset( $option[ $product->id ] );
|
|
$updated = true;
|
|
} else if ( isset( $product->file ) && array_key_exists( $product->file, $option ) ) { /* file_slug_map */
|
|
unset( $option[ $product->file ] );
|
|
$updated = true;
|
|
}
|
|
|
|
if ( $updated ) {
|
|
$this->_accounts->set_option( $option_name, $option );
|
|
|
|
$options[ $option_name ] = $option;
|
|
|
|
$has_updated_option = true;
|
|
}
|
|
}
|
|
|
|
// Clear the product's data from the primary storage.
|
|
if ( isset( $this->_storage_data[ $slug ] ) ) {
|
|
unset( $this->_storage_data[ $slug ] );
|
|
$has_updated_option = true;
|
|
}
|
|
|
|
// Clear from GC timestamp.
|
|
// @todo - This perhaps needs a separate garbage collector for all expired products. But the chance of left-over is very slim.
|
|
if ( isset( $this->_gc_timestamp[ $slug ] ) ) {
|
|
unset( $this->_gc_timestamp[ $slug ] );
|
|
$has_updated_option = true;
|
|
}
|
|
}
|
|
|
|
$this->_accounts->set_option( 'gc_timestamp', $this->_gc_timestamp );
|
|
$this->_accounts->set_option( $this->_type . '_data', $this->_storage_data );
|
|
|
|
return $has_updated_option;
|
|
}
|
|
|
|
private function get_all_option_names() {
|
|
return array_merge(
|
|
array(
|
|
'admin_notices',
|
|
'updates',
|
|
'all_licenses',
|
|
'addons',
|
|
'id_slug_type_path_map',
|
|
'file_slug_map',
|
|
),
|
|
$this->_options_names
|
|
);
|
|
}
|
|
|
|
private function get_products() {
|
|
$products = $this->_accounts->get_option( $this->_plural_type, array() );
|
|
|
|
// Fill any missing product found in the primary storage.
|
|
// @todo - This wouldn't be needed if we use dedicated GC design for every options. The options themselves would provide such information.
|
|
foreach( $this->_storage_data as $slug => $product_data ) {
|
|
if ( ! isset( $products[ $slug ] ) ) {
|
|
$products[ $slug ] = (object) $product_data;
|
|
}
|
|
}
|
|
|
|
$this->update_gc_timestamp( $products );
|
|
|
|
return $products;
|
|
}
|
|
|
|
private function get_products_to_clean() {
|
|
$products_to_clean = array();
|
|
|
|
$products = $this->get_products();
|
|
|
|
foreach ( $products as $slug => $product_data ) {
|
|
if ( ! is_object( $product_data ) ) {
|
|
continue;
|
|
}
|
|
|
|
if ( $this->is_product_active( $slug ) ) {
|
|
continue;
|
|
}
|
|
|
|
$is_addon = ( ! empty( $product_data->parent_plugin_id ) );
|
|
|
|
if ( ! $is_addon ) {
|
|
$products_to_clean[] = $product_data;
|
|
} else {
|
|
/**
|
|
* If add-on, add to the beginning of the array so that add-ons are removed before their parent. This is to prevent an unexpected issue when an add-on exists but its parent was already removed.
|
|
*/
|
|
array_unshift( $products_to_clean, $product_data );
|
|
}
|
|
}
|
|
|
|
return $products_to_clean;
|
|
}
|
|
|
|
/**
|
|
* @param string $slug
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function is_product_active( $slug ) {
|
|
$instances = Freemius::_get_all_instances();
|
|
|
|
foreach ( $instances as $instance ) {
|
|
if ( $instance->get_slug() === $slug ) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$expiration_time = fs_get_optional_constant( 'WP_FS__GARBAGE_COLLECTOR_EXPIRATION_TIME_SECS', ( WP_FS__TIME_WEEK_IN_SEC * 4 ) );
|
|
|
|
if ( $this->get_last_load_timestamp( $slug ) > ( time() - $expiration_time ) ) {
|
|
// Last activation was within the last 4 weeks.
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function load_options() {
|
|
$options = array();
|
|
$option_names = $this->get_all_option_names();
|
|
|
|
foreach ( $option_names as $option_name ) {
|
|
$options[ $option_name ] = $this->_accounts->get_option( $option_name, array() );
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Updates the garbage collector timestamp, only if it was not already set by the product's primary storage.
|
|
*
|
|
* @param array $products
|
|
*
|
|
* @return void
|
|
*/
|
|
private function update_gc_timestamp( $products ) {
|
|
foreach ($products as $slug => $product_data) {
|
|
if ( ! is_object( $product_data ) && ! is_array( $product_data ) ) {
|
|
continue;
|
|
}
|
|
|
|
|
|
// If the product is active, we don't need to update the gc_timestamp.
|
|
if ( isset( $this->_storage_data[ $slug ]['last_load_timestamp'] ) ) {
|
|
continue;
|
|
}
|
|
|
|
// First try to check if the product is present in the primary storage. If so update that.
|
|
if ( isset( $this->_storage_data[ $slug ] ) ) {
|
|
$this->_storage_data[ $slug ]['last_load_timestamp'] = time();
|
|
} else if ( ! isset( $this->_gc_timestamp[ $slug ] ) ) {
|
|
// If not, fallback to the gc_timestamp, but we don't want to update it more than once.
|
|
$this->_gc_timestamp[ $slug ] = time();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function get_last_load_timestamp( $slug ) {
|
|
if ( isset( $this->_storage_data[ $slug ]['last_load_timestamp'] ) ) {
|
|
return $this->_storage_data[ $slug ]['last_load_timestamp'];
|
|
}
|
|
|
|
return isset( $this->_gc_timestamp[ $slug ] ) ?
|
|
$this->_gc_timestamp[ $slug ] :
|
|
// This should never happen, but if it does, let's assume the product is not expired.
|
|
time();
|
|
}
|
|
}
|
|
|
|
class FS_User_Garbage_Collector implements FS_I_Garbage_Collector {
|
|
private $_accounts;
|
|
|
|
private $_types;
|
|
|
|
function __construct( FS_Options $_accounts, array $types ) {
|
|
$this->_accounts = $_accounts;
|
|
$this->_types = $types;
|
|
}
|
|
|
|
function clean() {
|
|
$users = Freemius::get_all_users();
|
|
|
|
$user_has_install_map = $this->get_user_has_install_map();
|
|
|
|
if ( count( $users ) === count( $user_has_install_map ) ) {
|
|
return false;
|
|
}
|
|
|
|
$products_user_id_license_ids_map = $this->_accounts->get_option( 'user_id_license_ids_map', array() );
|
|
|
|
$has_updated_option = false;
|
|
|
|
foreach ( $users as $user_id => $user ) {
|
|
if ( ! isset( $user_has_install_map[ $user_id ] ) ) {
|
|
unset( $users[ $user_id ] );
|
|
|
|
foreach( $products_user_id_license_ids_map as $product_id => $user_id_license_ids_map ) {
|
|
unset( $user_id_license_ids_map[ $user_id ] );
|
|
|
|
if ( empty( $user_id_license_ids_map ) ) {
|
|
unset( $products_user_id_license_ids_map[ $product_id ] );
|
|
} else {
|
|
$products_user_id_license_ids_map[ $product_id ] = $user_id_license_ids_map;
|
|
}
|
|
}
|
|
|
|
$this->_accounts->set_option( 'users', $users );
|
|
$this->_accounts->set_option( 'user_id_license_ids_map', $products_user_id_license_ids_map );
|
|
|
|
$has_updated_option = true;
|
|
}
|
|
}
|
|
|
|
return $has_updated_option;
|
|
}
|
|
|
|
private function get_user_has_install_map() {
|
|
$user_has_install_map = array();
|
|
|
|
foreach ( $this->_types as $product_type ) {
|
|
$option_name = ( WP_FS__MODULE_TYPE_PLUGIN !== $product_type ) ?
|
|
"{$product_type}_sites" :
|
|
'sites';
|
|
|
|
$installs = $this->_accounts->get_option( $option_name, array() );
|
|
|
|
foreach ( $installs as $install ) {
|
|
$user_has_install_map[ $install->user_id ] = true;
|
|
}
|
|
}
|
|
|
|
return $user_has_install_map;
|
|
}
|
|
}
|
|
|
|
// Main entry-level class.
|
|
class FS_Garbage_Collector implements FS_I_Garbage_Collector {
|
|
/**
|
|
* @var FS_Garbage_Collector
|
|
* @since 2.6.0
|
|
*/
|
|
private static $_instance;
|
|
|
|
/**
|
|
* @return FS_Garbage_Collector
|
|
*/
|
|
static function instance() {
|
|
if ( ! isset( self::$_instance ) ) {
|
|
self::$_instance = new self();
|
|
}
|
|
|
|
return self::$_instance;
|
|
}
|
|
|
|
#endregion
|
|
|
|
private function __construct() {
|
|
}
|
|
|
|
function clean() {
|
|
$_accounts = FS_Options::instance( WP_FS__ACCOUNTS_OPTION_NAME, true );
|
|
|
|
$products_cleaners = $this->get_product_cleaners( $_accounts );
|
|
|
|
$has_cleaned = false;
|
|
|
|
foreach ( $products_cleaners as $products_cleaner ) {
|
|
if ( $products_cleaner->clean() ) {
|
|
$has_cleaned = true;
|
|
}
|
|
}
|
|
|
|
if ( $has_cleaned ) {
|
|
$user_cleaner = new FS_User_Garbage_Collector(
|
|
$_accounts,
|
|
array_keys( $products_cleaners )
|
|
);
|
|
|
|
$user_cleaner->clean();
|
|
}
|
|
|
|
// @todo - We need a garbage collector for `all_plugins` and `active_plugins` (and variants of themes).
|
|
|
|
// Always store regardless of whether there were cleaned products or not since during the process, the logic may set the last load timestamp of some products.
|
|
$_accounts->store();
|
|
}
|
|
|
|
/**
|
|
* @param FS_Options $_accounts
|
|
*
|
|
* @return FS_I_Garbage_Collector[]
|
|
*/
|
|
private function get_product_cleaners( FS_Options $_accounts ) {
|
|
/**
|
|
* @var FS_I_Garbage_Collector[] $products_cleaners
|
|
*/
|
|
$products_cleaners = array();
|
|
|
|
$products_cleaners[ WP_FS__MODULE_TYPE_PLUGIN ] = new FS_Product_Garbage_Collector(
|
|
$_accounts,
|
|
array(
|
|
'sites',
|
|
'plans',
|
|
'plugins',
|
|
),
|
|
WP_FS__MODULE_TYPE_PLUGIN
|
|
);
|
|
|
|
$products_cleaners[ WP_FS__MODULE_TYPE_THEME ] = new FS_Product_Garbage_Collector(
|
|
$_accounts,
|
|
array(
|
|
'theme_sites',
|
|
'theme_plans',
|
|
'themes',
|
|
),
|
|
WP_FS__MODULE_TYPE_THEME
|
|
);
|
|
|
|
return $products_cleaners;
|
|
}
|
|
} |