$data ) { $path_parts = explode( '/', $plugin_path ); if ( $path_parts[0] === $slug ) { return $plugin_path; } } return false; } /** * Get an array of installed plugin slugs. * * @return array */ public static function get_installed_plugin_slugs() { return array_map( function ( $plugin_path ) { $path_parts = explode( '/', $plugin_path ); return $path_parts[0]; }, array_keys( get_plugins() ) ); } /** * Get an array of installed plugins with their file paths as a key value pair. * * @return array */ public static function get_installed_plugins_paths() { $plugins = get_plugins(); $installed_plugins = array(); foreach ( $plugins as $path => $plugin ) { $path_parts = explode( '/', $path ); $slug = $path_parts[0]; $installed_plugins[ $slug ] = $path; } return $installed_plugins; } /** * Get an array of active plugin slugs. * * The list will include both network active and site active plugins. * * @return array The list of active plugin slugs. */ public static function get_active_plugin_slugs() { return array_unique( array_map( function ( $absolute_path ) { // Make the path relative to the plugins directory. $plugin_path = str_replace( WP_PLUGIN_DIR . '/', '', $absolute_path ); // Split the path to get the plugin slug (aka the directory name). $path_parts = explode( '/', $plugin_path ); return $path_parts[0]; }, // Use this method as it is the most bulletproof way to get the active plugins. wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins() ) ); } /** * Checks if a plugin is installed. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return bool */ public static function is_plugin_installed( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); return $plugin_path ? array_key_exists( $plugin_path, get_plugins() ) : false; } /** * Checks if a plugin is active. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return bool */ public static function is_plugin_active( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); return $plugin_path && \is_plugin_active( $plugin_path ); } /** * Get plugin data. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return array|false */ public static function get_plugin_data( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); $plugins = get_plugins(); return isset( $plugins[ $plugin_path ] ) ? $plugins[ $plugin_path ] : false; } /** * Error codes whose WP_Error data is machine output rather than a readable detail. * * `activate_plugin()` puts the plugin's whole captured output buffer in the error data, * which is never something a merchant can act on. */ private const OPAQUE_ERROR_DATA_CODES = array( 'unexpected_output' ); /** * Longest reason we pass on. * * Reasons reach a notice, the persisted install log option and a Tracks property, so an * upgrader payload of unknown size must never travel untrimmed. */ private const MAX_ERROR_REASON_LENGTH = 300; /** * Make a reason safe to show, store and transmit. * * Reasons come from WordPress and from values that reach us through filters, so they can * carry markup or be arbitrarily long. WordPress also writes some messages as multiple * paragraphs, so block boundaries become spaces first: stripping the tags would otherwise * run the last word of one paragraph into the first word of the next. * * @since 11.2.0 * * @param string $text The raw reason. * @return string The reason, HTML-stripped, whitespace-collapsed and length-capped. */ private static function normalize_reason( string $text ): string { $spaced = preg_replace( '#<(?:/p|/div|/li|/h[1-6]|br\s*/?)>#i', ' ', $text ) ?? $text; $reason = trim( wp_strip_all_tags( $spaced, true ) ); if ( mb_strlen( $reason ) > self::MAX_ERROR_REASON_LENGTH ) { $reason = trim( mb_substr( $reason, 0, self::MAX_ERROR_REASON_LENGTH ) ) . "\u{2026}"; } return $reason; } /** * Read a version requirement from plugin header data. * * The headers come from `get_plugin_data()` on the unpacked package, whose values pass * through filters, so the value is not guaranteed to be a string. * * @since 11.2.0 * * @param mixed $value The raw `RequiresPHP` or `RequiresWP` value. * @return string The version, or an empty string when the value is not one. */ private static function get_required_version( $value ): string { if ( ! is_string( $value ) && ! is_numeric( $value ) ) { return ''; } return trim( (string) $value ); } /** * Build a merchant-readable reason from the first WP_Error among the given candidates. * * WordPress's upgrader reports the real failure (unmet PHP/WP version, filesystem, download) * as a WP_Error whose message is a short sentence and whose data is often the detail sentence. * Non-error candidates (false, null, true) are skipped. * * The data is only appended when it reads as a detail: codes listed in * OPAQUE_ERROR_DATA_CODES carry raw plugin output instead, and the result is capped at * MAX_ERROR_REASON_LENGTH because callers persist and transmit it. * * @since 11.2.0 * * @param mixed ...$candidates Values that may be WP_Error instances, in order of preference. * @return string The reason, HTML-stripped, trimmed and length-capped, or an empty string when none is available. */ public static function get_error_reason( ...$candidates ): string { foreach ( $candidates as $candidate ) { if ( ! is_wp_error( $candidate ) ) { continue; } // Both come from WP_Error::add(), which stores whatever a filter callback passed in. $message = $candidate->get_error_message(); $data = $candidate->get_error_data(); $parts = is_string( $message ) ? array( $message ) : array(); if ( is_string( $data ) && ! in_array( $candidate->get_error_code(), self::OPAQUE_ERROR_DATA_CODES, true ) ) { $parts[] = $data; } return self::normalize_reason( implode( ' ', array_map( 'trim', $parts ) ) ); } return ''; } /** * Compose a reason for an install that failed an unmet PHP or WordPress version requirement. * * WordPress reports these with copy written for the zip-upload flow ("the uploaded plugin requires"), * so build our own sentence from the versions it actually checked: the package headers it read * in `Plugin_Upgrader::check_package()`, which can differ from the wp.org listing metadata. * * @since 11.2.0 * * @param mixed $error The WP_Error from the upgrader skin, or any non-error value. * @param mixed $plugin_data The headers of the package that failed, as `Plugin_Upgrader::$new_plugin_data` holds them. * @return string The reason, or an empty string when the error is not a requirement failure. */ public static function get_requirements_error_reason( $error, $plugin_data ): string { if ( ! is_wp_error( $error ) || ! is_array( $plugin_data ) ) { return ''; } switch ( $error->get_error_code() ) { case 'incompatible_php_required_version': $required_php = self::get_required_version( $plugin_data['RequiresPHP'] ?? null ); if ( '' === $required_php ) { return ''; } return self::normalize_reason( sprintf( /* translators: 1: PHP version the plugin requires, 2: PHP version this site runs. */ __( 'It requires PHP %1$s or newer, but this site runs PHP %2$s.', 'woocommerce' ), $required_php, PHP_VERSION ) ); case 'incompatible_wp_required_version': $required_wp = self::get_required_version( $plugin_data['RequiresWP'] ?? null ); if ( '' === $required_wp ) { return ''; } return self::normalize_reason( sprintf( /* translators: 1: WordPress version the plugin requires, 2: WordPress version this site runs. */ __( 'It requires WordPress %1$s or newer, but this site runs WordPress %2$s.', 'woocommerce' ), $required_wp, get_bloginfo( 'version' ) ) ); } return ''; } /** * Install an array of plugins. * * @param array $plugins Plugins to install. * @param PluginsInstallLogger|null $logger an optional logger. * @param string|null $source place where the request is coming from. * * @return array */ public static function install_plugins( $plugins, ?PluginsInstallLogger $logger = null, ?string $source = null ) { /** * Filter the list of plugins to install. * * @param array $plugins A list of the plugins to install. * * @since 6.4.0 */ $plugins = apply_filters( 'woocommerce_admin_plugins_pre_install', $plugins ); if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ) ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/plugin-install.php'; include_once ABSPATH . '/wp-admin/includes/plugin.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-plugin-upgrader.php'; $existing_plugins = self::get_installed_plugins_paths(); $installed_plugins = array(); $results = array(); $time = array(); $errors = new WP_Error(); $install_start_time = time(); foreach ( $plugins as $plugin ) { $slug = sanitize_key( $plugin ); $logger && $logger->install_requested( $plugin ); if ( isset( $existing_plugins[ $slug ] ) ) { $installed_plugins[] = $plugin; $logger && $logger->installed( $plugin, 0 ); continue; } $start_time = microtime( true ); $api = plugins_api( 'plugin_information', array( 'slug' => $slug, 'fields' => array( 'sections' => false, ), ) ); if ( is_wp_error( $api ) ) { $properties = array( 'error_message' => sprintf( // translators: %s: plugin slug (example: woocommerce-services). __( 'We couldn\'t install `%s`. Try again in a few minutes, or install it later from the Extensions page.', 'woocommerce' ), $slug ), 'api_error_message' => $api->get_error_message(), 'slug' => $slug, ); wc_admin_record_tracks_event( 'install_plugin_error', $properties ); /** * Action triggered when a plugin API call failed. * * @param string $slug The plugin slug. * @param WP_Error $api The API response. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_install_api_error', $slug, $api ); $error_message = __( 'Try again in a few minutes, or install it later from the Extensions page.', 'woocommerce' ); $errors->add( $plugin, $error_message ); $logger && $logger->add_error( $plugin, $error_message ); continue; } /** * Action triggered before a plugin is installed. * * @since 9.8 */ do_action( 'woocommerce_plugins_install_before', $slug, $source ); // Plugin_Upgrader::install() only returns errors raised once the package is unpacked. // A failed filesystem connection, download or unpack is only reported to the skin, so // keep it there for the reason below. $skin = new class() extends Automatic_Upgrader_Skin { /** * The last error the upgrader reported. * * @var WP_Error|null */ public $last_error = null; /** * Record the error before passing it on. * * @param string|WP_Error $errors The error the upgrader reports. */ public function error( $errors ): void { if ( is_wp_error( $errors ) ) { $this->last_error = $errors; } parent::error( $errors ); } }; $upgrader = new Plugin_Upgrader( $skin ); $result = $upgrader->install( $api->download_link ); // result can be false or WP_Error. $results[ $plugin ] = $result; $time[ $plugin ] = round( ( microtime( true ) - $start_time ) * 1000 ); if ( is_wp_error( $result ) || is_null( $result ) ) { $properties = array( 'error_message' => sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed.', 'woocommerce' ), $slug ), 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, 'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ), 'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null', ); wc_admin_record_tracks_event( 'install_plugin_error', $properties ); /** * Action triggered when a plugin installation fails. * * @param string $slug The plugin slug. * @param object $api The plugin API object. * @param WP_Error|null $result The result of the plugin installation. * @param Plugin_Upgrader $upgrader The plugin upgrader. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_install_error', $slug, $api, $result, $upgrader ); $install_error_message = self::get_requirements_error_reason( $skin->result, $upgrader->new_plugin_data ); if ( '' === $install_error_message ) { $install_error_message = self::get_error_reason( $skin->result, $result, $skin->last_error ); } if ( '' === $install_error_message ) { $install_error_message = __( 'Try again, or install it manually. If it keeps failing, contact your host.', 'woocommerce' ); } $errors->add( $plugin, $install_error_message ); $logger && $logger->add_error( $plugin, $install_error_message ); continue; } $installed_plugins[] = $plugin; $logger && $logger->installed( $plugin, $time[ $plugin ] ); /** * Action triggered after a plugin is installed. * * @since 9.8 */ do_action( 'woocommerce_plugins_install_after', $slug, $source ); } $data = array( 'installed' => $installed_plugins, 'results' => $results, 'errors' => $errors, 'time' => $time, ); $logger && $logger->complete( array_merge( $data, array( 'start_time' => $install_start_time ) ) ); return $data; } /** * Callback registered by OnboardingPlugins::install_and_activate_async. * * It is used to call install_plugins and activate_plugins with a custom logger. * * @param array $plugins A list of plugins to install. * @param string $job_id An unique job I.D. * @param string|null $source The source of the request. * * @return bool */ public static function install_and_activate_plugins_async_callback( array $plugins, string $job_id, ?string $source = null ) { $option_name = 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id; $logger = new AsyncPluginsInstallLogger( $option_name ); self::install_plugins( $plugins, $logger, $source ); self::activate_plugins( $plugins, $logger ); return true; } /** * Schedule plugin installation. * * @param array $plugins Plugins to install. * * @return string Job ID. */ public static function schedule_install_plugins( $plugins ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } $job_id = uniqid(); WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_install_callback', array( $plugins ) ); return $job_id; } /** * Activate the requested plugins. * * @param array $plugins Plugins. * @param PluginsInstallLogger|null $logger Logger. * * @return WP_Error|array Plugin Status */ public static function activate_plugins( $plugins, ?PluginsInstallLogger $logger = null ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; // the mollie-payments-for-woocommerce plugin calls `WP_Filesystem()` during it's activation hook, which crashes without this include. require_once ABSPATH . 'wp-admin/includes/file.php'; /** * Filter the list of plugins to activate. * * @param array $plugins A list of the plugins to activate. * * @since 6.4.0 */ $plugins = apply_filters( 'woocommerce_admin_plugins_pre_activate', $plugins ); $plugin_paths = self::get_installed_plugins_paths(); $errors = new WP_Error(); $activated_plugins = array(); foreach ( $plugins as $plugin ) { $slug = $plugin; $path = isset( $plugin_paths[ $slug ] ) ? $plugin_paths[ $slug ] : false; if ( ! $path ) { $message = __( 'The plugin is not installed yet.', 'woocommerce' ); $errors->add( $plugin, $message ); $logger && $logger->add_error( $plugin, $message ); continue; } $result = activate_plugin( $path ); if ( ! is_plugin_active( $path ) ) { /** * Action triggered when a plugin activation fails. * * @param string $slug The plugin slug. * @param null|WP_Error $result The result of the plugin activation. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_activate_error', $slug, $result ); $message = self::get_error_reason( $result ); if ( '' === $message ) { $message = __( 'The plugin could not be activated.', 'woocommerce' ); } $errors->add( $plugin, $message ); $logger && $logger->add_error( $plugin, $message ); continue; } $activated_plugins[] = $plugin; $logger && $logger->activated( $plugin ); } $data = array( 'activated' => $activated_plugins, 'active' => self::get_active_plugin_slugs(), 'errors' => $errors, ); return $data; } /** * Schedule plugin activation. * * @param array $plugins Plugins to activate. * * @return string Job ID. */ public static function schedule_activate_plugins( $plugins ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } $job_id = uniqid(); WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_activate_callback', array( $plugins, $job_id ) ); return $job_id; } /** * Installation status. * * @param int $job_id Job ID. * * @return array Job data. */ public static function get_installation_status( $job_id = null ) { $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_install_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); return self::get_action_data( $actions ); } /** * Gets the plugin data for the first action. * * @param array $actions Array of AS actions. * * @return array Array of action data. */ public static function get_action_data( $actions ) { $data = array(); foreach ( $actions as $action_id => $action ) { $store = new ActionScheduler_DBStore(); $args = $action->get_args(); $data[] = array( 'job_id' => $args[1], 'plugins' => $args[0], 'status' => $store->get_status( $action_id ), ); } return $data; } /** * Activation status. * * @param int $job_id Job ID. * * @return array Array of action data. */ public static function get_activation_status( $job_id = null ) { $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_activate_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); return self::get_action_data( $actions ); } /** * Show notices to connect to woocommerce.com for unconnected store in the plugin list. * * @return void */ public static function maybe_show_connect_notice_in_plugin_list() { if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } $notice_type = WC_Helper_Updater::get_woo_connect_notice_type(); // The outdated plugin risk state is reported in Site Health. if ( in_array( $notice_type, array( 'none', 'long' ), true ) ) { return; } $connect_page_url = add_query_arg( array( 'page' => 'wc-admin', 'tab' => 'my-subscriptions', 'path' => rawurlencode( '/extensions' ), 'utm_source' => 'pu', 'utm_campaign' => 'pu_setting_screen_connect', ), admin_url( 'admin.php' ) ); $notice_string = sprintf( /* translators: %s: Connect page URL */ __( 'Connect your store to WooCommerce.com to get updates and streamlined support for your subscriptions.', 'woocommerce' ), esc_url( $connect_page_url ) ); echo '
' . wp_kses_post( $notice_string ) . '
' . wp_kses_post( $notice['description'] ) . '
' . wp_kses_post( $notice['description'] ) . '