File "Plugin.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/Helpers/Plugin.php
File size: 17.37 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace WPForms\Helpers;

use Automatic_Upgrader_Skin;
use WP_Error;

/**
 * Source of truth for installing, activating, and upgrading plugins and WPForms addons.
 *
 * A plugin is identified by its main file relative to `WP_PLUGIN_DIR`
 * (e.g. `contact-form-7/wp-contact-form-7.php`). WPForms addons are plugins too: they
 * live in a `wpforms-{slug}` directory and are detected by that prefix, so their package
 * comes from the license API (gated by the site's license level) while every other plugin
 * downloads from WordPress.org. "Already installed" is a check for the file on disk; a
 * plugin that is already present is never re-downloaded, only activated or upgraded.
 *
 * @since 2.0.0
 */
class Plugin {

	/**
	 * Cache of plugin header data, keyed by plugin basename.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	private static $plugin_data_cache = [];

	/**
	 * Install a plugin or WPForms addon by its main file, downloading it when missing.
	 *
	 * Runs the WP installer through `PluginSilentUpgrader` with a neutral upgrader skin so
	 * the call works in AJAX, REST, CLI, and plain PHP contexts alike. Addons are detected
	 * by the `wpforms-` directory prefix and resolved against the license API: one whose
	 * tier is above the site's license is refused. A plugin already on disk is reported as
	 * a `wpforms_install_plugin_exists` error (with the plugin file as error data) instead
	 * of being re-downloaded, so the caller can decide whether to activate or upgrade it.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`: a WordPress.org
	 *                            plugin (e.g. `contact-form-7/wp-contact-form-7.php`) or a
	 *                            WPForms addon (e.g. `wpforms-stripe/wpforms-stripe.php`).
	 *
	 * @return array|WP_Error {
	 *     Install result on success, `WP_Error` on failure or when already present
	 *     (error code `wpforms_install_plugin_exists`).
	 *
	 *     @type string $plugin Plugin main file.
	 * }
	 */
	public function install( string $plugin_file ) { // phpcs:ignore WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks, Generic.Metrics.CyclomaticComplexity.TooHigh

		$generic_error = esc_html__( 'Could not install the plugin. Please download and install it manually.', 'wpforms-lite' );

		if ( $plugin_file === '' ) {
			return new WP_Error( 'wpforms_install_plugin_empty', $generic_error );
		}

		// Already on disk: report it so the caller can activate or upgrade it instead of re-downloading.
		if ( $this->is_installed( $plugin_file ) ) {
			return new WP_Error(
				'wpforms_install_plugin_exists',
				esc_html__( 'The plugin is already installed.', 'wpforms-lite' ),
				$plugin_file
			);
		}

		$is_addon = $this->is_addon( $plugin_file );

		if ( ! wpforms_can_install( $is_addon ? 'addon' : 'plugin' ) ) {
			return new WP_Error( 'wpforms_install_plugin_forbidden', $generic_error );
		}

		// Addons are gated by the site's license level.
		if ( $is_addon && ! $this->has_addon_access( $plugin_file ) ) {
			return new WP_Error(
				'wpforms_install_plugin_access',
				esc_html__( 'Your license level does not include this addon. Please upgrade your plan to install it.', 'wpforms-lite' )
			);
		}

		$download_url = $this->get_download_url( $plugin_file );

		if ( $download_url === '' ) {
			return new WP_Error( 'wpforms_install_plugin_url', $generic_error );
		}

		$installed = $this->install_from_url( $plugin_file, $download_url );

		if ( is_wp_error( $installed ) ) {
			return $installed;
		}

		return [ 'plugin' => $plugin_file ];
	}

	/**
	 * Install a plugin from an explicit download URL.
	 *
	 * For callers that already hold a resolved package URL (e.g. the Setup Wizard's
	 * Lite-to-Pro upgrade, where the license API returns the URL). The catalog/license
	 * checks `install()` runs are intentionally skipped: the caller-supplied URL
	 * already encodes the access decision. The install-capability gate still applies,
	 * so a `public` entry point can never install without the right permission.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file  Plugin main file relative to `WP_PLUGIN_DIR`.
	 * @param string $download_url Resolved package URL.
	 *
	 * @return true|WP_Error
	 */
	public function install_from_url( string $plugin_file, string $download_url ) {

		$generic_error = esc_html__( 'Could not install the plugin. Please download and install it manually.', 'wpforms-lite' );
		$is_addon      = $this->is_addon( $plugin_file );

		if ( ! wpforms_can_install( $is_addon ? 'addon' : 'plugin' ) ) {
			return new WP_Error( 'wpforms_install_plugin_forbidden', $generic_error );
		}

		if ( ! $this->prepare_filesystem() ) {
			return new WP_Error( 'wpforms_install_plugin_filesystem', $generic_error );
		}

		$installer = new PluginSilentUpgrader( new Automatic_Upgrader_Skin() );

		if ( ! method_exists( $installer, 'install' ) ) {
			return new WP_Error( 'wpforms_install_plugin_installer', $generic_error );
		}

		$installed = $installer->install( $download_url );

		if ( is_wp_error( $installed ) ) {
			return $installed;
		}

		wp_cache_flush();

		if ( ! $this->is_installed( $plugin_file ) ) {
			return new WP_Error( 'wpforms_install_plugin_failed', $generic_error );
		}

		return true;
	}

	/**
	 * Activate an installed plugin by its main file.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file (e.g. `wp-mail-smtp/wp_mail_smtp.php`).
	 *
	 * @return true|WP_Error True on success, `WP_Error` on failure.
	 */
	public function activate( string $plugin_file ) {

		if ( ! current_user_can( 'activate_plugins' ) ) {
			return new WP_Error(
				'wpforms_activate_plugin_forbidden',
				esc_html__( 'Plugin activation is disabled for you on this site.', 'wpforms-lite' )
			);
		}

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

		$activated = activate_plugin( $plugin_file );

		if ( is_wp_error( $activated ) ) {
			return $activated;
		}

		/** This action is documented in includes/admin/ajax-actions.php. */
		do_action( 'wpforms_plugin_activated', $plugin_file ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName

		return true;
	}

	/**
	 * Upgrade an installed plugin or WPForms addon to its latest release.
	 *
	 * Overwrites the plugin on disk with the latest package by clearing the destination
	 * first, but only when a genuinely newer version is available: when the installed build
	 * is the same as or newer than the available one (e.g. a patched or beta build), the
	 * call is a no-op that returns success without downgrading. Addons are detected by the
	 * `wpforms-` directory prefix and pull the latest license build from the license API
	 * (gated by the site's license level); every other plugin pulls the latest
	 * WordPress.org build.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`: a WordPress.org
	 *                            plugin (e.g. `contact-form-7/wp-contact-form-7.php`) or a
	 *                            WPForms addon (e.g. `wpforms-stripe/wpforms-stripe.php`).
	 *
	 * @return array|WP_Error {
	 *     Upgrade result on success, `WP_Error` on failure.
	 *
	 *     @type string $plugin Plugin main file.
	 * }
	 */
	public function upgrade( string $plugin_file ) { // phpcs:ignore WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks

		$generic_error = esc_html__( 'Could not upgrade the plugin. Please update it manually.', 'wpforms-lite' );

		if ( $plugin_file === '' || ! $this->is_installed( $plugin_file ) ) {
			return new WP_Error( 'wpforms_upgrade_plugin_missing', $generic_error );
		}

		if ( ! current_user_can( 'update_plugins' ) || ! wp_is_file_mod_allowed( 'wpforms_can_install' ) ) {
			return new WP_Error( 'wpforms_upgrade_plugin_forbidden', $generic_error );
		}

		// Addons are gated by the site's license level.
		if ( $this->is_addon( $plugin_file ) && ! $this->has_addon_access( $plugin_file ) ) {
			return new WP_Error(
				'wpforms_upgrade_plugin_access',
				esc_html__( 'Your license level does not include this addon. Please upgrade your plan to update it.', 'wpforms-lite' )
			);
		}

		// Never downgrade: skip the overwrite unless a genuinely newer version is available.
		if ( ! $this->has_available_update( $plugin_file ) ) {
			return [ 'plugin' => $plugin_file ];
		}

		$download_url = $this->get_download_url( $plugin_file );

		if ( $download_url === '' ) {
			return new WP_Error( 'wpforms_upgrade_plugin_url', $generic_error );
		}

		if ( ! $this->prepare_filesystem() ) {
			return new WP_Error( 'wpforms_upgrade_plugin_filesystem', $generic_error );
		}

		$upgrader = new PluginSilentUpgrader( new Automatic_Upgrader_Skin() );

		if ( ! method_exists( $upgrader, 'run' ) ) {
			return new WP_Error( 'wpforms_upgrade_plugin_upgrader', $generic_error );
		}

		$result = $upgrader->run(
			[
				'package'                     => $download_url,
				'destination'                 => WP_PLUGIN_DIR,
				'clear_destination'           => true,
				'abort_if_destination_exists' => false,
				'clear_working'               => true,
				'hook_extra'                  => [
					'type'   => 'plugin',
					'action' => 'update',
					'plugin' => $plugin_file,
				],
			]
		);

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		if ( ! $result ) {
			return new WP_Error( 'wpforms_upgrade_plugin_failed', $generic_error );
		}

		wp_cache_flush();

		return [ 'plugin' => $plugin_file ];
	}

	/**
	 * Whether a plugin basename belongs to a genuine WPForms addon.
	 *
	 * Stricter than {@see self::is_addon()}: on top of the `wpforms-` directory
	 * prefix it excludes the core plugin and reads the installed plugin header to
	 * confirm the `WPForms` author, ruling out third-party forks that reuse the
	 * prefix. It therefore needs the plugin present on disk, so it detects
	 * already-installed addons — use `is_addon()` to route a not-yet-installed
	 * download. Shared so the addon-detection logic lives in one place across both
	 * editions instead of being duplicated per consumer.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin Plugin basename (e.g. `wpforms-stripe/wpforms-stripe.php`).
	 *
	 * @return bool
	 */
	public static function is_wpforms_addon( string $plugin ): bool {

		// The core plugin (Lite or Pro) is never an addon.
		if ( $plugin === 'wpforms/wpforms.php' || strpos( $plugin, 'wpforms-' ) !== 0 ) {
			return false;
		}

		// Forks reuse the `wpforms-` prefix, so confirm the author from the header.
		$author = strtolower( (string) ( self::get_plugin_data( $plugin )['AuthorName'] ?? '' ) );

		return $author === 'wpforms';
	}

	/**
	 * Read and cache a plugin's header data by basename.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin Plugin basename (e.g. `wpforms-stripe/wpforms-stripe.php`).
	 *
	 * @return array Plugin header data, or an empty array when the file is absent.
	 */
	public static function get_plugin_data( string $plugin ): array {

		if ( isset( self::$plugin_data_cache[ $plugin ] ) ) {
			return self::$plugin_data_cache[ $plugin ];
		}

		$plugin_file = trailingslashit( WP_PLUGIN_DIR ) . $plugin;

		if ( ! file_exists( $plugin_file ) ) {
			self::$plugin_data_cache[ $plugin ] = [];

			return [];
		}

		self::ensure_plugin_functions();

		self::$plugin_data_cache[ $plugin ] = get_plugin_data( $plugin_file, false, false );

		return self::$plugin_data_cache[ $plugin ];
	}

	/**
	 * Ensure the WordPress plugin admin functions are loaded.
	 *
	 * Available in admin requests, but not in REST/cron contexts where the wizard
	 * and update routines also run.
	 *
	 * @since 2.0.0
	 */
	public static function ensure_plugin_functions(): void {

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

	/**
	 * Prepare the WordPress filesystem and upgrader prerequisites.
	 *
	 * Loads the upgrader classes, resolves filesystem credentials without emitting
	 * the credentials form, and prevents WordPress from fetching translations during
	 * the run (which would break JS output).
	 *
	 * @since 2.0.0
	 *
	 * @return bool Whether the filesystem is ready for an install or upgrade.
	 */
	private function prepare_filesystem(): bool { // phpcs:ignore WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks

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

		// The core upgrader calls get_plugin_data() and wp_clean_plugins_cache() mid-run,
		// and neither is loaded on REST/cron requests.
		if ( ! function_exists( 'get_plugin_data' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		if ( ! class_exists( 'Plugin_Upgrader', false ) ) {
			require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		}

		// Suppress any output the credentials form might emit when run outside an admin screen.
		ob_start();
		$credentials = request_filesystem_credentials( '', '', false, false );

		ob_end_clean();

		if ( $credentials === false || ! WP_Filesystem( $credentials ) ) {
			return false;
		}

		// Do not allow WordPress to search/download translations, as this will break JS output.
		remove_action( 'upgrader_process_complete', [ 'Language_Pack_Upgrader', 'async_upgrade' ], 20 );

		return true;
	}

	/**
	 * Whether a plugin main file belongs to a WPForms addon.
	 *
	 * Addons live in a `wpforms-{slug}` directory, so the directory prefix distinguishes
	 * them from WordPress.org plugins.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return bool
	 */
	private function is_addon( string $plugin_file ): bool {

		return strpos( dirname( $plugin_file ), 'wpforms-' ) === 0;
	}

	/**
	 * Whether the site's license level grants access to an addon.
	 *
	 * Reads the `plugin_allow` flag the Addons handler computes for the addon (its
	 * `has_access()` result, which maps `agency`/`ultimate` to `elite`). Returns false on
	 * Lite and whenever the handler is unavailable, so addons fail closed.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Addon main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return bool
	 */
	private function has_addon_access( string $plugin_file ): bool {

		$addons = wpforms()->obj( 'addons' );

		if ( ! $addons ) {
			return false;
		}

		$addon = (array) $addons->get_addon( dirname( $plugin_file ) );

		return ! empty( $addon['plugin_allow'] );
	}

	/**
	 * Whether a genuinely newer version of the plugin is available to install.
	 *
	 * Both WordPress core and the WPForms addon updater list a plugin in the
	 * `update_plugins` transient's `response` only when the available version is newer than
	 * the installed one, so a higher (e.g. patched or beta) build is absent from it. The
	 * available version is re-compared against the build on disk as well, so a stale
	 * transient entry can never trigger a downgrade. When the transient has never been
	 * populated (a fresh site, or a REST/cron context) the update list is refreshed once,
	 * so a genuine update is not mistaken for "already current".
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return bool
	 */
	private function has_available_update( string $plugin_file ): bool {

		$updates = get_site_transient( 'update_plugins' );

		// An absent cache means "never checked", not "nothing to update": populate it once.
		// A populated cache that simply omits the plugin legitimately means it is current.
		if ( ! is_object( $updates ) || ! isset( $updates->last_checked ) ) {
			wp_update_plugins();

			$updates = get_site_transient( 'update_plugins' );
		}

		if ( ! is_object( $updates ) || ! isset( $updates->response[ $plugin_file ]->new_version ) ) {
			return false;
		}

		$installed = $this->get_installed_version( $plugin_file );

		return $installed === '' || wpforms_version_compare( (string) $updates->response[ $plugin_file ]->new_version, $installed, '>' );
	}

	/**
	 * Read the installed version of a plugin from its header on disk.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return string Version string, or an empty string when it cannot be determined.
	 */
	private function get_installed_version( string $plugin_file ): string {

		self::ensure_plugin_functions();

		return get_plugins()[ $plugin_file ]['Version'] ?? '';
	}

	/**
	 * Whether a plugin's main file is already present under `WP_PLUGIN_DIR`.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return bool
	 */
	private function is_installed( string $plugin_file ): bool {

		return file_exists( trailingslashit( WP_PLUGIN_DIR ) . $plugin_file );
	}

	/**
	 * Build the download URL for a plugin main file.
	 *
	 * For a WPForms addon the license-keyed package URL comes from the Addons handler
	 * (empty on Lite or for an unknown/inaccessible addon). For every other plugin the
	 * directory is the WordPress.org slug, so `contact-form-7/wp-contact-form-7.php`
	 * resolves to `https://downloads.wordpress.org/plugin/contact-form-7.zip`.
	 *
	 * @since 2.0.0
	 *
	 * @param string $plugin_file Plugin main file relative to `WP_PLUGIN_DIR`.
	 *
	 * @return string
	 */
	private function get_download_url( string $plugin_file ): string {

		$slug = dirname( $plugin_file );

		if ( ! $this->is_addon( $plugin_file ) ) {
			return sprintf( 'https://downloads.wordpress.org/plugin/%s.zip', $slug );
		}

		$addons = wpforms()->obj( 'addons' );

		return $addons && method_exists( $addons, 'get_url' ) ? (string) $addons->get_url( $slug ) : '';
	}
}