: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
* @copyright Copyright (c) 2015, Freemius, Inc.
* @license https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License Version 3
* @author Leo Fajardo (@leorw)
if ( ! defined( 'ABSPATH' ) ) {
* Manages the detection of clones and provides the logged-in WordPress user with options for manually resolving them.
* @property int $clone_identification_timestamp
* @property int $temporary_duplicate_mode_selection_timestamp
* @property int $temporary_duplicate_notice_shown_timestamp
* @property string $request_handler_id
* @property int $request_handler_timestamp
* @property int $request_handler_retries_count
* @property bool $hide_manual_resolution
* @property array $new_blog_install_map
private $_network_storage;
const CLONE_RESOLUTION_MAX_EXECUTION_TIME = 180;
const CLONE_RESOLUTION_MAX_RETRIES = 3;
const TEMPORARY_DUPLICATE_PERIOD = WP_FS__TIME_WEEK_IN_SEC * 2;
const OPTION_NAME = 'clone_resolution';
const OPTION_MANAGER_NAME = 'clone_management';
const OPTION_TEMPORARY_DUPLICATE = 'temporary_duplicate';
const OPTION_LONG_TERM_DUPLICATE = 'long_term_duplicate';
const OPTION_NEW_HOME = 'new_home';
#--------------------------------------------------------------------------------
#--------------------------------------------------------------------------------
private static $_instance;
* @return FS_Clone_Manager
static function instance() {
if ( ! isset( self::$_instance ) ) {
self::$_instance = new self();
private function __construct() {
$this->_storage = FS_Option_Manager::get_manager( WP_FS___OPTION_PREFIX . self::OPTION_MANAGER_NAME, true );
$this->_network_storage = FS_Option_Manager::get_manager( WP_FS___OPTION_PREFIX . self::OPTION_MANAGER_NAME, true, true );
$this->maybe_migrate_options();
$this->_notices = FS_Admin_Notices::instance( 'global_clone_resolution_notices', '', '', true );
$this->_logger = FS_Logger::get_logger( WP_FS__SLUG . '_' . '_clone_manager', WP_FS__DEBUG_SDK, WP_FS__ECHO_DEBUG_SDK );
* Migrate clone resolution options from 2.5.0 array-based structure, to a new flat structure.
* The reason this logic is not in a separate migration script is that we want to be 100% sure data is migrated before any execution of clone logic.
* @todo Delete this one in the future.
private function maybe_migrate_options() {
foreach ( $storages as $storage ) {
$clone_data = $storage->get_option( self::OPTION_NAME );
if ( is_array( $clone_data ) && ! empty( $clone_data ) ) {
foreach ( $clone_data as $key => $val ) {
if ( ! is_null( $val ) ) {
$storage->set_option( $key, $val );
$storage->unset_option( self::OPTION_NAME, true );
* @author Leo Fajardo (@leorw)
if ( Freemius::is_admin_post() ) {
add_action( 'admin_post_fs_clone_resolution', array( $this, '_handle_clone_resolution' ) );
if ( Freemius::is_ajax() ) {
Freemius::add_ajax_action_static( 'handle_clone_resolution', array( $this, '_clone_resolution_action_ajax_handler' ) );
empty( $this->get_clone_identification_timestamp() ) &&
! fs_is_network_admin() ||
! ( $this->is_clone_resolution_options_notice_shown() || $this->is_temporary_duplicate_notice_shown() )
$this->hide_clone_admin_notices();
} else if ( ! Freemius::is_cron() && ! Freemius::is_admin_post() ) {
$this->try_resolve_clone_automatically();
$this->maybe_show_clone_admin_notice();
add_action( 'admin_footer', array( $this, '_add_clone_resolution_javascript' ) );
* Retrieves the timestamp that was stored when a clone was identified.
function get_clone_identification_timestamp() {
return $this->get_option( 'clone_identification_timestamp', true );
* @author Leo Fajardo (@leorw)
* @param string $sdk_last_version
function maybe_update_clone_resolution_support_flag( $sdk_last_version ) {
if ( null !== $this->hide_manual_resolution ) {
$this->hide_manual_resolution = (
! empty( $sdk_last_version ) &&
version_compare( $sdk_last_version, '2.5.0', '<' )
* Stores the time when a clone was identified.
function store_clone_identification_timestamp() {
$this->clone_identification_timestamp = time();
* Retrieves the timestamp for the temporary duplicate mode's expiration.
function get_temporary_duplicate_expiration_timestamp() {
$temporary_duplicate_mode_start_timestamp = $this->was_temporary_duplicate_mode_selected() ?
$this->temporary_duplicate_mode_selection_timestamp :
$this->get_clone_identification_timestamp();
return ( $temporary_duplicate_mode_start_timestamp + self::TEMPORARY_DUPLICATE_PERIOD );
* Determines if the SDK should handle clones. The SDK handles clones only up to 3 times with 3 min interval.
private function should_handle_clones() {
if ( ! isset( $this->request_handler_timestamp ) ) {
if ( $this->request_handler_retries_count >= self::CLONE_RESOLUTION_MAX_RETRIES ) {
// Give the logic that handles clones enough time to finish (it is given 3 minutes for now).
return ( time() > ( $this->request_handler_timestamp + self::CLONE_RESOLUTION_MAX_EXECUTION_TIME ) );
* @author Leo Fajardo (@leorw)
function should_hide_manual_resolution() {
return ( true === $this->hide_manual_resolution );
* Executes the clones handler logic if it should be executed, i.e., based on the return value of the should_handle_clones() method.
* @author Leo Fajardo (@leorw)
function maybe_run_clone_resolution() {
if ( ! $this->should_handle_clones() ) {
$this->request_handler_retries_count = isset( $this->request_handler_retries_count ) ?
( $this->request_handler_retries_count + 1 ) :
$this->request_handler_timestamp = time();
$handler_id = ( rand() . microtime() );
$this->request_handler_id = $handler_id;
// Add cookies to trigger request with the same user access permissions.
foreach ( $_COOKIE as $name => $value ) {
$cookies[] = new WP_Http_Cookie( array(
admin_url( 'admin-post.php' ),
'action' => 'fs_clone_resolution',
'handler_id' => $handler_id,
* Executes the clones handler logic.
* @author Leo Fajardo (@leorw)
function _handle_clone_resolution() {
$handler_id = fs_request_get( 'handler_id' );
if ( empty( $handler_id ) ) {
! isset( $this->request_handler_id ) ||
$this->request_handler_id !== $handler_id
if ( ! $this->try_automatic_resolution() ) {
$this->clear_temporary_duplicate_notice_shown_timestamp();
#--------------------------------------------------------------------------------
#region Automatic Clone Resolution
#--------------------------------------------------------------------------------
* @var array All installs cache.
* Checks if a given instance's install is a clone of another subsite in the network.
* @author Vova Feldman (@svovaf)
private function find_network_subsite_clone_install( Freemius $instance ) {
if ( ! is_multisite() ) {
// Not a multi-site network.
if ( ! isset( $this->all_installs ) ) {
$this->all_installs = Freemius::get_all_modules_sites();
// Check if there's another blog that has the same site.
$module_type = $instance->get_module_type();
$sites_by_module_type = ! empty( $this->all_installs[ $module_type ] ) ?
$this->all_installs[ $module_type ] :
$slug = $instance->get_slug();
$sites_by_slug = ! empty( $sites_by_module_type[ $slug ] ) ?
$sites_by_module_type[ $slug ] :
$current_blog_id = get_current_blog_id();
$current_install = $instance->get_site();
foreach ( $sites_by_slug as $site ) {
$current_install->id == $site->id &&
$current_blog_id != $site->blog_id
// Clone is identical to an install on another subsite in the network.
* Tries to find a different install of the context product that is associated with the current URL and loads it.
* @author Leo Fajardo (@leorw)
* @param Freemius $instance
private function find_other_install_by_url( Freemius $instance, $url ) {
$result = $instance->get_api_user_scope()->get( "/plugins/{$instance->get_id()}/installs.json?url=" . urlencode( $url ) . "&all=true", true );
$current_install = $instance->get_site();
if ( $instance->is_api_result_object( $result, 'installs' ) ) {
foreach ( $result->installs as $install ) {
if ( $install->id == $current_install->id ) {
$instance->is_only_premium() &&
! FS_Plugin_License::is_valid_id( $install->license_id )
// When searching for installs by a URL, the API will first strip any paths and search for any matching installs by the subdomain. Therefore, we need to test if there's a match between the current URL and the install's URL before continuing.
if ( $url !== fs_strip_url_protocol( untrailingslashit( $install->url ) ) ) {
// Found a different install that is associated with the current URL, load it and replace the current install with it if no updated install is found.
* Delete the current install associated with a given instance and opt-in/activate-license to create a fresh install.
* @author Vova Feldman (@svovaf)
* @param Freemius $instance
* @param string|false $license_key
* @return bool TRUE if successfully connected. FALSE if failed and had to restore install from backup.
private function delete_install_and_connect( Freemius $instance, $license_key = false ) {
$user = Freemius::_get_user_by_id( $instance->get_site()->user_id );
$instance->delete_current_install( true );
if ( ! is_object( $user ) ) {
// Get logged-in WordPress user.
$current_user = Freemius::_get_current_wp_user();
// Find the relevant FS user by email address.
$user = Freemius::_get_user_by_email( $current_user->user_email );
if ( is_object( $user ) ) {
// When a clone is found, we prefer to use the same user of the original install for the opt-in.
$instance->install_with_user( $user, $license_key, false, false );
// If no user is found, activate with the license.
if ( is_object( $instance->get_site() ) ) {
// Install successfully created.
$instance->restore_backup_site();
* Try to resolve the clone situation automatically.
* @param Freemius $instance
* @param string $current_url
* @param bool $is_localhost
* @param bool|null $is_clone_of_network_subsite
* @return bool If managed to automatically resolve the clone.
private function try_resolve_clone_automatically_by_instance(
$is_clone_of_network_subsite = null
// Try to find a different install of the context product that is associated with the current URL.
$associated_install = $this->find_other_install_by_url( $instance, $current_url );
if ( is_object( $associated_install ) ) {
// Replace the current install with a different install that is associated with the current URL.
$instance->store_site( new FS_Site( clone $associated_install ) );
$instance->sync_install( array( 'is_new_site' => true ), true );
if ( ! $instance->is_premium() ) {
// For free products, opt-in with the context user to create new install.
return $this->delete_install_and_connect( $instance );
$license = $instance->_get_license();
$can_activate_license = ( is_object( $license ) && ! $license->is_utilized( $is_localhost ) );
if ( ! $can_activate_license ) {
// License can't be activated, therefore, can't be automatically resolved.
if ( ! WP_FS__IS_LOCALHOST_FOR_SERVER && ! $is_localhost ) {
$is_clone_of_network_subsite = ( ! is_null( $is_clone_of_network_subsite ) ) ?
$is_clone_of_network_subsite :
is_object( $this->find_network_subsite_clone_install( $instance ) );