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.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/wp-cloud-client/1.2.0/src/Handler/TriggerJetpackBackupHandler.php
<?php

declare(strict_types=1);

namespace VPlugins\WPCloudClient\Handler;

use VPlugins\WPCloudClient\Settings\SettingsRepository;
use VPlugins\WPCloudClient\Support\Logger;

/**
 * Triggers an on-demand Jetpack Backup via the wp-cloud-manager relay.
 *
 * Action name: trigger_jetpack_backup
 *
 * No required params.
 *
 * The Jetpack partner bearer token is never stored on the WP install.
 * The manager relay holds the partner token server-side and proxies
 * the request to Automattic's rewind API on behalf of this site.
 *
 * Success response example:
 * {
 *   "triggered": true,
 *   "blog_id":   123456789,
 *   "backup_id": "abc-123",
 *   "status":    "queued",
 *   "message":   "Backup triggered successfully."
 * }
 *
 * Error conditions (returned as structured 400/500 via ActionController):
 *   - Jetpack not connected on this site          → 400 validation_error
 *   - WPCC_MANAGER_URL not configured             → 400 validation_error
 *   - Jetpack Backup license not active           → 400 validation_error
 *   - Jetpack partner token not provisioned       → 400 validation_error
 *   - Manager rejected auth (401)                 → 500 action_failed
 *   - Transport failure or manager server error   → 500 action_failed
 */
final class TriggerJetpackBackupHandler extends AbstractHandler {

	/**
	 * Class constructor.
	 *
	 * @param SettingsRepository $settings Plugin settings repository.
	 * @param Logger             $logger   Logger instance.
	 */
	public function __construct(
		private readonly SettingsRepository $settings,
		private readonly Logger $logger,
	) {}

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

	/**
	 * Trigger a Jetpack Backup via the wp-cloud-manager relay.
	 *
	 * @param array<string, mixed> $params Action parameters (unused).
	 * @return array<string, mixed> Backup trigger result.
	 *
	 * @throws \InvalidArgumentException When Jetpack is not connected, manager URL is missing,
	 *                                   the backup license is not active, or the partner token
	 *                                   is not provisioned.
	 * @throws \RuntimeException         On transport failure, manager auth failure (401),
	 *                                   or manager server error.
	 */
	public function execute( array $params ): array {
		$blogId     = $this->resolveBlogId();
		$managerUrl = $this->resolveManagerUrl();
		return $this->callManagerRelay( $blogId, $managerUrl );
	}

	/**
	 * Read the Jetpack blog ID from wp_options and validate the connection.
	 *
	 * @return int Jetpack blog ID.
	 * @throws \InvalidArgumentException When Jetpack is not connected or options are corrupt.
	 */
	private function resolveBlogId(): int {
		$jetpackOptions = (array) get_option( 'jetpack_options', [] );

		if ( ! isset( $jetpackOptions['id'] ) ) {
			$this->logger->warning( 'TriggerJetpackBackup: Jetpack is not connected — blog ID missing from jetpack_options' );
			throw new \InvalidArgumentException(
				'Jetpack is not connected to this site. Connect Jetpack before triggering a backup.'
			);
		}

		if ( ! is_numeric( $jetpackOptions['id'] ) ) {
			$this->logger->warning(
				'TriggerJetpackBackup: jetpack_options[id] is not numeric — option may be corrupt',
				[ 'type' => gettype( $jetpackOptions['id'] ) ]
			);
			throw new \InvalidArgumentException(
				'Jetpack options are corrupt — blog ID is not a valid integer. Check jetpack_options in wp_options.'
			);
		}

		$blogId = (int) $jetpackOptions['id'];

		if ( $blogId <= 0 ) {
			$this->logger->warning( 'TriggerJetpackBackup: Jetpack is not connected — blog ID is zero or negative' );
			throw new \InvalidArgumentException(
				'Jetpack is not connected to this site. Connect Jetpack before triggering a backup.'
			);
		}

		return $blogId;
	}

	/**
	 * Resolve and validate the wp-cloud-manager base URL.
	 *
	 * @return string Manager base URL.
	 * @throws \InvalidArgumentException When WPCC_MANAGER_URL is not configured.
	 */
	private function resolveManagerUrl(): string {
		$url = $this->settings->getManagerUrl();
		if ( '' === $url ) {
			throw new \InvalidArgumentException(
				'WPCC_MANAGER_URL is not configured. Set the WPCC_MANAGER_URL constant or wpcc_manager_url filter.'
			);
		}
		return $url;
	}

	/**
	 * POST to the wp-cloud-manager relay endpoint and handle the response.
	 *
	 * @param int    $blogId     Jetpack blog ID.
	 * @param string $managerUrl Base URL of wp-cloud-manager.
	 * @return array<string, mixed> Parsed success payload.
	 *
	 * @throws \InvalidArgumentException On 402 (no license) or 424 (partner token unprovisioned).
	 * @throws \RuntimeException         On transport failures, 401 auth errors, 5xx, or unexpected codes.
	 */
	private function callManagerRelay( int $blogId, string $managerUrl ): array {
		$url = rtrim( $managerUrl, '/' ) . '/wordpress/v1/jetpack/backup/trigger';

		$response = wp_remote_post(
			$url,
			[
				'body'        => wp_json_encode( [ 'blog_id' => $blogId ] ),
				'data_format' => 'body',
				'headers'     => [
					'Content-Type'   => 'application/json',
					'X-WPCC-Site-ID' => (string) $this->settings->getSiteId(),
					'X-WPCC-API-Key' => (string) $this->settings->getApiKey(),
				],
				'timeout'     => 15,
			]
		);

		$this->handleTransportError( $response, $blogId );

		$statusCode = (int) wp_remote_retrieve_response_code( $response );
		$body       = wp_remote_retrieve_body( $response );

		if ( 202 === $statusCode ) {
			return $this->parseSuccessBody( $blogId, $body );
		}

		$this->handleErrorResponse( $statusCode, $body );
	}

	/**
	 * Throw a RuntimeException if the HTTP response is a WP_Error (transport failure).
	 *
	 * @param array|\WP_Error $response  Response from wp_remote_post.
	 * @param int             $blogId    Blog ID for log context.
	 * @return void
	 * @throws \RuntimeException On transport failure.
	 */
	private function handleTransportError( array|\WP_Error $response, int $blogId ): void {
		if ( ! is_wp_error( $response ) ) {
			return;
		}
		$this->logger->error(
			'TriggerJetpackBackup: transport error',
			[
				'blog_id' => $blogId,
				'error'   => $response->get_error_message(),
			]
		);
		throw new \RuntimeException( 'Failed to reach wp-cloud-manager. See server log for details.' );
	}

	/**
	 * Parse and validate the 202 response body from the manager relay.
	 *
	 * @param int    $blogId Blog ID for log context and the response payload.
	 * @param string $body   Raw response body JSON.
	 * @return array<string, mixed>
	 * @throws \RuntimeException When the body is not valid JSON, or backup_id is missing.
	 */
	private function parseSuccessBody( int $blogId, string $body ): array {
		$decoded = json_decode( $body, true );

		if ( ! is_array( $decoded ) ) {
			$this->logger->error(
				'TriggerJetpackBackup: manager returned 202 with non-JSON body',
				[
					'blog_id'      => $blogId,
					'body_excerpt' => substr( $body, 0, 256 ),
				]
			);
			throw new \RuntimeException( 'wp-cloud-manager returned an invalid response body.' );
		}

		if ( ! isset( $decoded['backup_id'] ) ) {
			$this->logger->error(
				'TriggerJetpackBackup: manager 202 response is missing backup_id',
				[
					'blog_id'      => $blogId,
					'body_excerpt' => substr( $body, 0, 256 ),
				]
			);
			throw new \RuntimeException( 'wp-cloud-manager response did not include backup_id.' );
		}

		$backupId = (string) $decoded['backup_id'];
		$status   = isset( $decoded['status'] ) ? (string) $decoded['status'] : 'queued';

		$this->logger->info(
			'TriggerJetpackBackup: backup triggered',
			[
				'blog_id'   => $blogId,
				'backup_id' => $backupId,
				'status'    => $status,
			]
		);

		return [
			'triggered' => true,
			'blog_id'   => $blogId,
			'backup_id' => $backupId,
			'status'    => $status,
			'message'   => 'Backup triggered successfully.',
		];
	}

	/**
	 * Map non-202 status codes to typed exceptions.
	 *
	 * @param int    $statusCode HTTP status code.
	 * @param string $body       Raw response body (excerpt logged for diagnostics).
	 *
	 * @throws \InvalidArgumentException On 402 (no license) or 424 (partner token unprovisioned).
	 * @throws \RuntimeException         On 401, 5xx, or any other unexpected status code.
	 */
	private function handleErrorResponse( int $statusCode, string $body ): never {
		$this->logger->error(
			'TriggerJetpackBackup: manager returned non-202',
			[
				'status'       => $statusCode,
				'body_excerpt' => substr( $body, 0, 512 ),
			]
		);

		match ( true ) {
			402 === $statusCode => throw new \InvalidArgumentException(
				'Jetpack Backup license is not active on this site. Provision a license before triggering a backup.'
			),
			424 === $statusCode => throw new \InvalidArgumentException(
				'Jetpack partner token is not provisioned for this site. Contact WP Cloud support.'
			),
			401 === $statusCode => throw new \RuntimeException(
				'Manager rejected the request — check Site ID and API Key configuration.'
			),
			$statusCode >= 500  => throw new \RuntimeException(
				sprintf( 'wp-cloud-manager returned a server error (HTTP %d). Try again later.', $statusCode )
			),
			default             => throw new \RuntimeException(
				sprintf( 'Unexpected response from wp-cloud-manager (HTTP %d).', $statusCode )
			),
		};
	}
}