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';
}
}
}