woocommerce/woocommerce

View on GitHub
includes/wccom-site/class-wc-wccom-site-installer.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
/**
 * WooCommerce.com Product Installation.
 *
 * @package WooCommerce\WCCom
 * @since   3.7.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * WC_WCCOM_Site_Installer Class
 *
 * Contains functionalities to install products via WooCommerce.com helper connection.
 */
class WC_WCCOM_Site_Installer {

    /**
     * Error message returned install_package if the folder already exists.
     *
     * @var string
     */
    private static $folder_exists = 'folder_exists';

    /**
     * Default state.
     *
     * @var array
     */
    private static $default_state = array(
        'status'       => 'idle',
        'steps'        => array(),
        'current_step' => null,
    );

    /**
     * Represents product step state.
     *
     * @var array
     */
    private static $default_step_state = array(
        'download_url'   => '',
        'product_type'   => '',
        'last_step'      => '',
        'last_error'     => '',
        'download_path'  => '',
        'unpacked_path'  => '',
        'installed_path' => '',
        'activate'       => false,
    );

    /**
     * Product install steps. Each step is a method name in this class that
     * will be passed with product ID arg \WP_Upgrader instance.
     *
     * @var array
     */
    private static $install_steps = array(
        'get_product_info',
        'download_product',
        'unpack_product',
        'move_product',
        'activate_product',
    );

    /**
     * Get the product install state.
     *
     * @since 3.7.0
     * @param string $key Key in state data. If empty key is passed array of
     *                    state will be returned.
     * @return array Product install state.
     */
    public static function get_state( $key = '' ) {
        $state = WC_Helper_Options::get( 'product_install', self::$default_state );
        if ( ! empty( $key ) ) {
            return isset( $state[ $key ] ) ? $state[ $key ] : null;
        }

        return $state;
    }

    /**
     * Update the product install state.
     *
     * @since 3.7.0
     * @param string $key   Key in state data.
     * @param mixed  $value Value.
     */
    public static function update_state( $key, $value ) {
        $state = WC_Helper_Options::get( 'product_install', self::$default_state );

        $state[ $key ] = $value;
        WC_Helper_Options::update( 'product_install', $state );
    }

    /**
     * Reset product install state.
     *
     * @since 3.7.0
     * @param array $products List of product IDs.
     */
    public static function reset_state( $products = array() ) {
        WC()->queue()->cancel_all( 'woocommerce_wccom_install_products' );
        WC_Helper_Options::update( 'product_install', self::$default_state );
    }

    /**
     * Schedule installing given list of products.
     *
     * @since 3.7.0
     * @param array $products Array of products where key is product ID and
     *                        element is install args.
     * @return array State.
     */
    public static function schedule_install( $products ) {
        $state  = self::get_state();
        $status = ! empty( $state['status'] ) ? $state['status'] : '';
        if ( 'in-progress' === $status ) {
            return $state;
        }
        self::update_state( 'status', 'in-progress' );

        $steps = array_fill_keys( array_keys( $products ), self::$default_step_state );
        self::update_state( 'steps', $steps );

        self::update_state( 'current_step', null );

        $args = array(
            'products' => $products,
        );

        // Clear the cache of customer's subscription before asking for them.
        // Thus, they will be re-fetched from WooCommerce.com after a purchase.
        WC_Helper::_flush_subscriptions_cache();

        WC()->queue()->cancel_all( 'woocommerce_wccom_install_products', $args );
        WC()->queue()->add( 'woocommerce_wccom_install_products', $args );

        return self::get_state();
    }

    /**
     * Install a given product IDs.
     *
     * Run via `woocommerce_wccom_install_products` hook.
     *
     * @since 3.7.0
     * @param array $products Array of products where key is product ID and
     *                        element is install args.
     */
    public static function install( $products ) {
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
        require_once ABSPATH . 'wp-admin/includes/plugin.php';

        WP_Filesystem();
        $upgrader = new WP_Upgrader( new Automatic_Upgrader_Skin() );
        $upgrader->init();
        wp_clean_plugins_cache();

        foreach ( $products as $product_id => $install_args ) {
            self::install_product( $product_id, $install_args, $upgrader );
        }

        self::finish_installation();
    }

    /**
     * Finish installation by updating the state.
     *
     * @since 3.7.0
     */
    private static function finish_installation() {
        $state = self::get_state();
        if ( empty( $state['steps'] ) ) {
            return;
        }

        foreach ( $state['steps'] as $step ) {
            if ( ! empty( $step['last_error'] ) ) {
                $state['status'] = 'has_error';
                break;
            }
        }

        if ( 'has_error' !== $state['status'] ) {
            $state['status'] = 'finished';
        }

        WC_Helper_Options::update( 'product_install', $state );
    }

    /**
     * Install a single product given its ID.
     *
     * @since 3.7.0
     * @param int          $product_id   Product ID.
     * @param array        $install_args Install args.
     * @param \WP_Upgrader $upgrader     Core class to handle installation.
     */
    private static function install_product( $product_id, $install_args, $upgrader ) {
        foreach ( self::$install_steps as $step ) {
            self::do_install_step( $product_id, $install_args, $step, $upgrader );
        }
    }

    /**
     * Perform product installation step.
     *
     * @since 3.7.0
     * @param int          $product_id   Product ID.
     * @param array        $install_args Install args.
     * @param string       $step         Installation step.
     * @param \WP_Upgrader $upgrader     Core class to handle installation.
     */
    private static function do_install_step( $product_id, $install_args, $step, $upgrader ) {
        $state_steps = self::get_state( 'steps' );
        if ( empty( $state_steps[ $product_id ] ) ) {
            $state_steps[ $product_id ] = self::$default_step_state;
        }

        if ( ! empty( $state_steps[ $product_id ]['last_error'] ) ) {
            return;
        }

        $state_steps[ $product_id ]['last_step'] = $step;

        if ( ! empty( $install_args['activate'] ) ) {
            $state_steps[ $product_id ]['activate'] = true;
        }

        self::update_state(
            'current_step',
            array(
                'product_id' => $product_id,
                'step'       => $step,
            )
        );

        $result = call_user_func( array( __CLASS__, $step ), $product_id, $upgrader );
        if ( is_wp_error( $result ) ) {
            $state_steps[ $product_id ]['last_error'] = $result->get_error_message();
        } else {
            switch ( $step ) {
                case 'get_product_info':
                    $state_steps[ $product_id ]['download_url'] = $result['download_url'];
                    $state_steps[ $product_id ]['product_type'] = $result['product_type'];
                    $state_steps[ $product_id ]['product_name'] = $result['product_name'];
                    break;
                case 'download_product':
                    $state_steps[ $product_id ]['download_path'] = $result;
                    break;
                case 'unpack_product':
                    $state_steps[ $product_id ]['unpacked_path'] = $result;
                    break;
                case 'move_product':
                    $state_steps[ $product_id ]['installed_path'] = $result['destination'];
                    if ( isset( $result[ self::$folder_exists ] ) ) {
                        $state_steps[ $product_id ]['warning'] = array(
                            'message'     => self::$folder_exists,
                            'plugin_info' => self::get_plugin_info( $state_steps[ $product_id ]['installed_path'] ),
                        );
                    }
                    break;
            }
        }

        self::update_state( 'steps', $state_steps );
    }

    /**
     * Get product info from its ID.
     *
     * @since 3.7.0
     * @param int $product_id Product ID.
     * @return array|\WP_Error
     */
    private static function get_product_info( $product_id ) {
        $product_info = array(
            'download_url' => '',
            'product_type' => '',
        );

        // Get product info from woocommerce.com.
        $request = WC_Helper_API::get(
            add_query_arg(
                array( 'product_id' => absint( $product_id ) ),
                'info'
            ),
            array(
                'authenticated' => true,
            )
        );

        if ( 200 !== wp_remote_retrieve_response_code( $request ) ) {
            return new WP_Error( 'product_info_failed', __( 'Failed to retrieve product info from woocommerce.com', 'woocommerce' ) );
        }

        $result = json_decode( wp_remote_retrieve_body( $request ), true );

        $product_info['product_type'] = $result['_product_type'];
        $product_info['product_name'] = $result['name'];

        if ( ! empty( $result['_wporg_product'] ) && ! empty( $result['download_link'] ) ) {
            // For wporg product, download is set already from info response.
            $product_info['download_url'] = $result['download_link'];
        } elseif ( ! WC_Helper::has_product_subscription( $product_id ) ) {
            // Non-wporg product needs subscription.
            return new WP_Error( 'missing_subscription', __( 'Missing product subscription', 'woocommerce' ) );
        } else {
            // Retrieve download URL for non-wporg product.
            WC_Helper_Updater::flush_updates_cache();
            $updates = WC_Helper_Updater::get_update_data();
            if ( empty( $updates[ $product_id ]['package'] ) ) {
                return new WP_Error( 'missing_product_package', __( 'Could not find product package.', 'woocommerce' ) );
            }

            $product_info['download_url'] = $updates[ $product_id ]['package'];
        }

        return $product_info;
    }

    /**
     * Download product by its ID and returns the path of the zip package.
     *
     * @since 3.7.0
     * @param int          $product_id Product ID.
     * @param \WP_Upgrader $upgrader   Core class to handle installation.
     * @return \WP_Error|string
     */
    private static function download_product( $product_id, $upgrader ) {
        $steps = self::get_state( 'steps' );
        if ( empty( $steps[ $product_id ]['download_url'] ) ) {
            return new WP_Error( 'missing_download_url', __( 'Could not find download url for the product.', 'woocommerce' ) );
        }
        return $upgrader->download_package( $steps[ $product_id ]['download_url'] );
    }

    /**
     * Unpack downloaded product.
     *
     * @since 3.7.0
     * @param int          $product_id Product ID.
     * @param \WP_Upgrader $upgrader   Core class to handle installation.
     * @return \WP_Error|string
     */
    private static function unpack_product( $product_id, $upgrader ) {
        $steps = self::get_state( 'steps' );
        if ( empty( $steps[ $product_id ]['download_path'] ) ) {
            return new WP_Error( 'missing_download_path', __( 'Could not find download path.', 'woocommerce' ) );
        }

        return $upgrader->unpack_package( $steps[ $product_id ]['download_path'], true );
    }

    /**
     * Move product to plugins directory.
     *
     * @since 3.7.0
     * @param int          $product_id Product ID.
     * @param \WP_Upgrader $upgrader   Core class to handle installation.
     * @return array|\WP_Error
     */
    private static function move_product( $product_id, $upgrader ) {
        $steps = self::get_state( 'steps' );
        if ( empty( $steps[ $product_id ]['unpacked_path'] ) ) {
            return new WP_Error( 'missing_unpacked_path', __( 'Could not find unpacked path.', 'woocommerce' ) );
        }

        $destination = 'plugin' === $steps[ $product_id ]['product_type']
            ? WP_PLUGIN_DIR
            : get_theme_root();

        $package = array(
            'source'        => $steps[ $product_id ]['unpacked_path'],
            'destination'   => $destination,
            'clear_working' => true,
            'hook_extra'    => array(
                'type'   => $steps[ $product_id ]['product_type'],
                'action' => 'install',
            ),
        );

        $result = $upgrader->install_package( $package );

        /**
         * If install package returns error 'folder_exists' threat as success.
         */
        if ( is_wp_error( $result ) && array_key_exists( self::$folder_exists, $result->errors ) ) {
            return array(
                self::$folder_exists => true,
                'destination'        => $result->error_data[ self::$folder_exists ],
            );
        }
        return $result;
    }

    /**
     * Activate product given its product ID.
     *
     * @since 3.7.0
     * @param int $product_id Product ID.
     * @return \WP_Error|null
     */
    private static function activate_product( $product_id ) {
        $steps = self::get_state( 'steps' );
        if ( ! $steps[ $product_id ]['activate'] ) {
            return null;
        }

        if ( 'plugin' === $steps[ $product_id ]['product_type'] ) {
            return self::activate_plugin( $product_id );
        }
        return self::activate_theme( $product_id );
    }

    /**
     * Activate plugin given its product ID.
     *
     * @since 3.7.0
     * @param int $product_id Product ID.
     * @return \WP_Error|null
     */
    private static function activate_plugin( $product_id ) {
        // Clear plugins cache used in `WC_Helper::get_local_woo_plugins`.
        wp_clean_plugins_cache();
        $filename = false;

        // If product is WP.org one, find out its filename.
        $dir_name = self::get_wporg_product_dir_name( $product_id );
        if ( false !== $dir_name ) {
            $filename = self::get_wporg_plugin_main_file( $dir_name );
        }

        if ( false === $filename ) {
            $plugins = wp_list_filter(
                WC_Helper::get_local_woo_plugins(),
                array(
                    '_product_id' => $product_id,
                )
            );

            $filename = is_array( $plugins ) && ! empty( $plugins ) ? key( $plugins ) : '';
        }

        if ( empty( $filename ) ) {
            return new WP_Error( 'unknown_filename', __( 'Unknown product filename.', 'woocommerce' ) );
        }

        return activate_plugin( $filename );
    }

    /**
     * Activate theme given its product ID.
     *
     * @since 3.7.0
     * @param int $product_id Product ID.
     * @return \WP_Error|null
     */
    private static function activate_theme( $product_id ) {
        // Clear plugins cache used in `WC_Helper::get_local_woo_themes`.
        wp_clean_themes_cache();
        $theme_slug = false;

        // If product is WP.org theme, find out its slug.
        $dir_name = self::get_wporg_product_dir_name( $product_id );
        if ( false !== $dir_name ) {
            $theme_slug = basename( $dir_name );
        }

        if ( false === $theme_slug ) {
            $themes = wp_list_filter(
                WC_Helper::get_local_woo_themes(),
                array(
                    '_product_id' => $product_id,
                )
            );

            $theme_slug = is_array( $themes ) && ! empty( $themes ) ? dirname( key( $themes ) ) : '';
        }

        if ( empty( $theme_slug ) ) {
            return new WP_Error( 'unknown_filename', __( 'Unknown product filename.', 'woocommerce' ) );
        }

        return switch_theme( $theme_slug );
    }

    /**
     * Get installed directory of WP.org product.
     *
     * @since 3.7.0
     * @param int $product_id Product ID.
     * @return bool|string
     */
    private static function get_wporg_product_dir_name( $product_id ) {
        $steps   = self::get_state( 'steps' );
        $product = $steps[ $product_id ];

        if ( empty( $product['download_url'] ) || empty( $product['installed_path'] ) ) {
            return false;
        }

        // Check whether product was downloaded from WordPress.org.
        $parsed_url = wp_parse_url( $product['download_url'] );
        if ( ! empty( $parsed_url['host'] ) && 'downloads.wordpress.org' !== $parsed_url['host'] ) {
            return false;
        }

        return basename( $product['installed_path'] );
    }

    /**
     * Get WP.org plugin's main file.
     *
     * @since 3.7.0
     * @param string $dir Directory name of the plugin.
     * @return bool|string
     */
    private static function get_wporg_plugin_main_file( $dir ) {
        // Ensure that exact dir name is used.
        $dir = trailingslashit( $dir );

        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $plugins = get_plugins();
        foreach ( $plugins as $path => $plugin ) {
            if ( 0 === strpos( $path, $dir ) ) {
                return $path;
            }
        }

        return false;
    }


    /**
     * Get plugin info
     *
     * @since 3.9.0
     * @param string $dir Directory name of the plugin.
     * @return bool|array
     */
    private static function get_plugin_info( $dir ) {
        $plugin_folder = basename( $dir );

        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $plugins = get_plugins();

        $related_plugins = array_filter(
            $plugins,
            function( $key ) use ( $plugin_folder ) {
                return strpos( $key, $plugin_folder . '/' ) === 0;
            },
            ARRAY_FILTER_USE_KEY
        );

        if ( 1 === count( $related_plugins ) ) {
            $plugin_key  = array_keys( $related_plugins )[0];
            $plugin_data = $plugins[ $plugin_key ];
            return array(
                'name'    => $plugin_data['Name'],
                'version' => $plugin_data['Version'],
                'active'  => is_plugin_active( $plugin_key ),
            );
        }
        return false;
    }
}