HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.31
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/wp-cloud-client/beta/src/Handler/TriggerPluginUpdateHandler.php
<?php

declare(strict_types=1);

namespace VPlugins\WPCloudClient\Handler;

use VPlugins\WPCloudClient\Support\Logger;
use VPlugins\WPCloudClient\Support\RollbackManager;

/**
 * Triggers an immediate plugin update or rollback via the REST actions endpoint.
 *
 * Action name: trigger_plugin_update
 *
 * Required params:
 *   - slug (string) - Plugin slug to update (e.g., "woocommerce/woocommerce").
 *
 * Optional params:
 *   - rollback (bool) – Revert to the version recorded before the last update.
 *
 * Update response example:
 * {
 *   "updated": true,
 *   "slug": "woocommerce/woocommerce",
 *   "previous_version": "8.0.0",
 *   "new_version": "8.1.0",
 *   "message": "Plugin updated from 8.0.0 to 8.1.0."
 * }
 *
 * Rollback response example:
 * {
 *   "rolled_back": true,
 *   "slug": "woocommerce/woocommerce",
 *   "previous_version": "8.1.0",
 *   "restored_version": "8.0.0",
 *   "message": "Plugin rolled back from 8.1.0 to 8.0.0."
 * }
 */
final class TriggerPluginUpdateHandler extends AbstractHandler {

	/**
	 * Class constructor.
	 *
	 * @param RollbackManager $rollbackManager Rollback manager instance.
	 * @param Logger          $logger          Logger instance.
	 */
	public function __construct(
		private readonly RollbackManager $rollbackManager,
		private readonly Logger $logger,
	) {}

	/**
	 * Return the action name.
	 *
	 * @return string
	 */
	public function action(): string {
		return 'trigger_plugin_update';
	}

	/**
	 * Trigger a plugin update or rollback.
	 *
	 * @param array<string, mixed> $params Action parameters.
	 * @return array<string, mixed> Operation result.
	 *
	 * @throws \InvalidArgumentException When required params are missing.
	 * @throws \RuntimeException On failure.
	 */
	public function execute( array $params ): array {
		$this->requireParams( $params, 'slug' );

		$slug     = (string) $params['slug'];
		$rollback = ! empty( $params['rollback'] );

		$this->validatePluginExists( $slug );
		$this->loadUpgraderDependencies();

		if ( $rollback ) {
			return $this->performRollback( $slug );
		}

		return $this->performUpdate( $slug );
	}

	/**
	 * Validate that the plugin exists.
	 *
	 * @param string $slug Plugin slug.
	 * @throws \InvalidArgumentException If plugin not found.
	 */
	private function validatePluginExists( string $slug ): void {
		$plugins = get_plugins();
		if ( ! isset( $plugins[ $slug ] ) ) {
			throw new \InvalidArgumentException( sprintf( 'Plugin "%s" not found.', $slug ) );
		}
	}

	/**
	 * Download and install the latest version of a plugin.
	 *
	 * @param string $slug Plugin slug.
	 * @return array<string, mixed>
	 * @throws \RuntimeException When the upgrade fails.
	 */
	private function performUpdate( string $slug ): array {
		$plugins        = get_plugins();
		$pluginInfo     = $plugins[ $slug ] ?? [];
		$currentVersion = $pluginInfo['Version'] ?? 'unknown';

		// Check if update is available.
		$transient = get_site_transient( 'update_plugins' );
		if ( ! is_object( $transient ) || ! isset( $transient->response[ $slug ] ) ) {
			return [
				'updated' => false,
				'slug'    => $slug,
				'message' => 'No update available for this plugin.',
			];
		}

		$updateData = $transient->response[ $slug ];
		$newVersion = $updateData->new_version ?? 'unknown';

		// Construct download URL for current version (for rollback).
		$pluginBasename = dirname( $slug );
		$downloadUrl    = sprintf(
			'https://downloads.wordpress.org/plugin/%s.%s.zip',
			$pluginBasename,
			$currentVersion
		);

		// Store current version + download URL for rollback.
		$this->rollbackManager->recordPreUpdateVersion( 'plugin', $slug, $currentVersion, $downloadUrl );

		// Snapshot active state before the upgrader runs — Plugin_Upgrader can dirty
		// the in-process option cache, so we read directly from the DB.
		$wasActive = $this->isPluginActive( $slug );

		// Perform the update.
		$upgrader = new \Plugin_Upgrader( new \Automatic_Upgrader_Skin() );
		$result   = $upgrader->upgrade( $slug );

		if ( is_wp_error( $result ) ) {
			$this->logger->error( 'Plugin update failed', $result->get_error_message() );
			throw new \RuntimeException(
				sprintf( 'Update failed for plugin "%s": %s', $slug, $result->get_error_message() )
			);
		}

		if ( $wasActive ) {
			$this->restoreActiveState( $slug );
		}

		// Clear the cached update-check transient so the next request to list
		// plugins returns fresh update availability data, not stale pre-update state.
		\wp_update_plugins();

		$this->logger->info(
			'Plugin updated',
			[
				'slug'       => $slug,
				'from'       => $currentVersion,
				'to'         => $newVersion,
				'was_active' => $wasActive,
			]
		);

		return [
			'updated'          => true,
			'slug'             => $slug,
			'previous_version' => $currentVersion,
			'new_version'      => $newVersion,
			'message'          => sprintf(
				'Plugin updated from %s to %s.',
				$currentVersion,
				$newVersion
			),
		];
	}

	/**
	 * Re-install the version that was active before the last update.
	 *
	 * @param string $slug Plugin slug.
	 * @return array<string, mixed>
	 * @throws \RuntimeException When no rollback target exists or the install fails.
	 */
	private function performRollback( string $slug ): array {
		if ( ! $this->rollbackManager->canRollback( 'plugin', $slug ) ) {
			throw new \RuntimeException(
				sprintf( 'No rollback target is recorded for plugin "%s". Trigger an update first.', $slug )
			);
		}

		$currentVersion = $this->getCurrentVersion( $slug );
		$targetVersion  = $this->rollbackManager->getPreviousVersion( 'plugin', $slug );
		$downloadUrl    = $this->rollbackManager->getDownloadUrl( 'plugin', $slug );

		if ( null === $downloadUrl || '' === $downloadUrl ) {
			throw new \RuntimeException(
				sprintf( 'No rollback download URL recorded for plugin "%s". Trigger an update first.', $slug )
			);
		}

		$wasActive = $this->isPluginActive( $slug );

		$upgrader = new \Plugin_Upgrader( new \Automatic_Upgrader_Skin() );
		$result   = $upgrader->install( $downloadUrl, [ 'overwrite_package' => true ] );

		if ( is_wp_error( $result ) ) {
			$this->logger->error( 'Plugin rollback failed', $result->get_error_message() );
			throw new \RuntimeException(
				sprintf( 'Rollback failed for plugin "%s": %s', $slug, $result->get_error_message() )
			);
		}

		if ( $wasActive ) {
			$this->restoreActiveState( $slug );
		}

		$this->rollbackManager->clearPreviousVersion( 'plugin', $slug );
		\wp_update_plugins();

		$this->logger->info(
			'Plugin rolled back',
			[
				'slug' => $slug,
				'from' => $currentVersion,
				'to'   => $targetVersion,
			]
		);

		return [
			'rolled_back'      => true,
			'slug'             => $slug,
			'previous_version' => $currentVersion,
			'restored_version' => $targetVersion,
			'message'          => sprintf(
				'Plugin rolled back from %s to %s.',
				$currentVersion,
				$targetVersion
			),
		];
	}

	/**
	 * Check whether a plugin is currently active by querying the DB directly.
	 *
	 * Plugin_Upgrader can dirty the in-process option cache during an upgrade, so
	 * is_plugin_active() (which reads from that cache) is unreliable here. Reading
	 * from $wpdb guarantees we see the persisted value.
	 *
	 * @param string $slug Plugin slug (e.g. "woocommerce/woocommerce").
	 * @return bool
	 */
	private function isPluginActive( string $slug ): bool {
		global $wpdb;
		$raw = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
				'active_plugins'
			)
		);
		if ( ! $raw ) {
			return false;
		}
		$active = \maybe_unserialize( $raw );
		return is_array( $active ) && in_array( $slug, $active, true );
	}

	/**
	 * Re-activate a plugin using activate_plugin() in silent mode.
	 *
	 * Silent mode suppresses the redirect WordPress would normally issue, which
	 * is unwanted inside a REST action handler.
	 *
	 * @param string $slug Plugin slug.
	 * @return void
	 */
	private function restoreActiveState( string $slug ): void {
		$result = \activate_plugin( $slug, '', false, true );
		if ( \is_wp_error( $result ) ) {
			$this->logger->warning(
				'Plugin re-activation after update/rollback failed',
				[
					'slug'  => $slug,
					'error' => $result->get_error_message(),
				]
			);
		}
	}

	/**
	 * Get the current version of a plugin.
	 *
	 * @param string $slug Plugin slug.
	 * @return string Current version.
	 */
	private function getCurrentVersion( string $slug ): string {
		$plugins = get_plugins();
		return $plugins[ $slug ]['Version'] ?? 'unknown';
	}

	/**
	 * Include required WordPress upgrader classes if not already loaded.
	 *
	 * @return void
	 */
	private function loadUpgraderDependencies(): void {
		if ( ! function_exists( 'request_filesystem_credentials' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

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

		if ( ! class_exists( \WP_Upgrader::class ) ) {
			require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		}

		if ( ! class_exists( \Automatic_Upgrader_Skin::class ) ) {
			require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader-skins.php';
		}
	}
}