diff --git a/assets/img/hosts/plesk.svg b/assets/img/hosts/plesk.svg new file mode 100644 index 00000000..89f0c158 --- /dev/null +++ b/assets/img/hosts/plesk.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + Plesk + diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index 84cba450..50f7aff5 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -128,6 +128,7 @@ private function register_core_integrations(): void { $this->register(new Providers\Cloudflare\Cloudflare_Integration()); $this->register(new Providers\Hestia\Hestia_Integration()); $this->register(new Providers\Enhance\Enhance_Integration()); + $this->register(new Providers\Plesk\Plesk_Integration()); $this->register(new Providers\Rocket\Rocket_Integration()); $this->register(new Providers\WPEngine\WPEngine_Integration()); $this->register(new Providers\WPMUDEV\WPMUDEV_Integration()); @@ -173,6 +174,7 @@ private function register_core_capabilities(): void { $this->add_capability('cloudflare', new Providers\Cloudflare\Cloudflare_Domain_Mapping()); $this->add_capability('hestia', new Providers\Hestia\Hestia_Domain_Mapping()); $this->add_capability('enhance', new Providers\Enhance\Enhance_Domain_Mapping()); + $this->add_capability('plesk', new Providers\Plesk\Plesk_Domain_Mapping()); $this->add_capability('rocket', new Providers\Rocket\Rocket_Domain_Mapping()); $this->add_capability('wpengine', new Providers\WPEngine\WPEngine_Domain_Mapping()); $this->add_capability('wpmudev', new Providers\WPMUDEV\WPMUDEV_Domain_Mapping()); diff --git a/inc/integrations/providers/plesk/class-plesk-domain-mapping.php b/inc/integrations/providers/plesk/class-plesk-domain-mapping.php new file mode 100644 index 00000000..8a2a37c2 --- /dev/null +++ b/inc/integrations/providers/plesk/class-plesk-domain-mapping.php @@ -0,0 +1,272 @@ + [ + 'send_domains' => __('Add domain aliases in Plesk whenever a new domain mapping gets created on your network', 'ultimate-multisite'), + 'autossl' => __('SSL certificates will be automatically provisioned if Plesk SSL It! or Let\'s Encrypt extension is active', 'ultimate-multisite'), + ], + 'will_not' => [], + ]; + + if (is_subdomain_install()) { + $explainer_lines['will']['send_sub_domains'] = __('Add subdomains in Plesk whenever a new site gets created on your network', 'ultimate-multisite'); + } + + return $explainer_lines; + } + + /** + * {@inheritdoc} + */ + public function register_hooks(): void { + + add_action('wu_add_domain', [$this, 'on_add_domain'], 10, 2); + add_action('wu_remove_domain', [$this, 'on_remove_domain'], 10, 2); + add_action('wu_add_subdomain', [$this, 'on_add_subdomain'], 10, 2); + add_action('wu_remove_subdomain', [$this, 'on_remove_subdomain'], 10, 2); + } + + /** + * Gets the parent Plesk_Integration for API calls. + * + * @since 2.5.0 + * @return Plesk_Integration + */ + private function get_plesk(): Plesk_Integration { + + /** @var Plesk_Integration */ + return $this->get_integration(); + } + + /** + * Called when a new domain is mapped. + * + * Creates a site alias in Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @param string $domain The domain name being mapped. + * @param int $site_id ID of the site receiving the mapping. + * @return void + */ + public function on_add_domain(string $domain, int $site_id): void { + + $base_domain = $this->get_plesk()->get_credential('WU_PLESK_DOMAIN'); + + if (empty($base_domain)) { + wu_log_add('integration-plesk', __('Missing WU_PLESK_DOMAIN; cannot add alias.', 'ultimate-multisite'), LogLevel::ERROR); + + return; + } + + // Create site alias + $this->log_response( + sprintf('Add alias %s', $domain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--create', $domain, '-domain', $base_domain], + ] + ) + ); + + // Optionally add www alias + if (! str_starts_with($domain, 'www.') && \WP_Ultimo\Managers\Domain_Manager::get_instance()->should_create_www_subdomain($domain)) { + $www = 'www.' . $domain; + + $this->log_response( + sprintf('Add alias %s', $www), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--create', $www, '-domain', $base_domain], + ] + ) + ); + } + } + + /** + * Called when a mapped domain is removed. + * + * Deletes the site alias from Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @param string $domain The domain name being removed. + * @param int $site_id ID of the site. + * @return void + */ + public function on_remove_domain(string $domain, int $site_id): void { + + // Delete site alias + $this->log_response( + sprintf('Delete alias %s', $domain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--delete', $domain], + ] + ) + ); + + // Also try to remove www alias + if (! str_starts_with($domain, 'www.')) { + $www = 'www.' . $domain; + + $this->log_response( + sprintf('Delete alias %s', $www), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/site_alias/call', + 'POST', + [ + 'params' => ['--delete', $www], + ] + ) + ); + } + } + + /** + * Called when a new subdomain is added. + * + * Creates a subdomain in Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @param string $subdomain The subdomain being added. + * @param int $site_id ID of the site. + * @return void + */ + public function on_add_subdomain(string $subdomain, int $site_id): void { + + $base_domain = $this->get_plesk()->get_credential('WU_PLESK_DOMAIN'); + + if (empty($base_domain)) { + wu_log_add('integration-plesk', __('Missing WU_PLESK_DOMAIN; cannot add subdomain.', 'ultimate-multisite'), LogLevel::ERROR); + + return; + } + + $this->log_response( + sprintf('Add subdomain %s', $subdomain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/subdomain/call', + 'POST', + [ + 'params' => ['--create', $subdomain, '-domain', $base_domain, '-www-root', '/httpdocs'], + ] + ) + ); + } + + /** + * Called when a subdomain is removed. + * + * Deletes the subdomain from Plesk via the CLI gateway. + * + * @since 2.5.0 + * + * @param string $subdomain The subdomain being removed. + * @param int $site_id ID of the site. + * @return void + */ + public function on_remove_subdomain(string $subdomain, int $site_id): void { + + $this->log_response( + sprintf('Delete subdomain %s', $subdomain), + $this->get_plesk()->send_plesk_api_request( + '/api/v2/cli/subdomain/call', + 'POST', + [ + 'params' => ['--delete', $subdomain], + ] + ) + ); + } + + /** + * Log an API response with a contextual label. + * + * @since 2.5.0 + * + * @param string $action_label Descriptive label for the action. + * @param array|\WP_Error $response The API response. + * @return void + */ + protected function log_response(string $action_label, $response): void { + + if (is_wp_error($response)) { + wu_log_add('integration-plesk', sprintf('[%s] %s', $action_label, $response->get_error_message()), LogLevel::ERROR); + + return; + } + + wu_log_add('integration-plesk', sprintf('[%s] %s', $action_label, wp_json_encode($response))); + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + return $this->get_plesk()->test_connection(); + } +} diff --git a/inc/integrations/providers/plesk/class-plesk-integration.php b/inc/integrations/providers/plesk/class-plesk-integration.php new file mode 100644 index 00000000..16d802c6 --- /dev/null +++ b/inc/integrations/providers/plesk/class-plesk-integration.php @@ -0,0 +1,241 @@ +set_description(__('Integrates with Plesk to add and remove domain aliases automatically when domains are mapped or removed.', 'ultimate-multisite')); + $this->set_logo(function_exists('wu_get_asset') ? wu_get_asset('plesk.svg', 'img/hosts') : ''); + $this->set_tutorial_link('https://ultimatemultisite.com/docs/user-guide/host-integrations/plesk'); + $this->set_constants( + [ + 'WU_PLESK_HOST', + ['WU_PLESK_API_KEY', 'WU_PLESK_PASSWORD'], + 'WU_PLESK_DOMAIN', + ] + ); + $this->set_optional_constants(['WU_PLESK_PORT', 'WU_PLESK_USERNAME']); + $this->set_supports(['autossl', 'no-instructions']); + } + + /** + * {@inheritdoc} + */ + public function detect(): bool { + + return false; + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + $response = $this->send_plesk_api_request('/api/v2/server', 'GET'); + + if (is_wp_error($response)) { + return $response; + } + + if (isset($response['platform'])) { + return true; + } + + return new \WP_Error( + 'connection-failed', + sprintf( + /* translators: %s is the error message from the API */ + __('Failed to connect to Plesk API: %s', 'ultimate-multisite'), + $response['error'] ?? __('Unknown error', 'ultimate-multisite') + ) + ); + } + + /** + * {@inheritdoc} + */ + public function get_fields(): array { + + return [ + 'WU_PLESK_HOST' => [ + 'title' => __('Plesk Host', 'ultimate-multisite'), + 'desc' => __('The hostname or IP address of your Plesk server (e.g., server.example.com). Do not include the port or protocol.', 'ultimate-multisite'), + 'placeholder' => __('e.g. server.example.com', 'ultimate-multisite'), + ], + 'WU_PLESK_PORT' => [ + 'title' => __('Plesk Port', 'ultimate-multisite'), + 'desc' => __('The port Plesk listens on. Defaults to 8443 if not set.', 'ultimate-multisite'), + 'placeholder' => __('8443', 'ultimate-multisite'), + 'value' => '8443', + ], + 'WU_PLESK_API_KEY' => [ + 'type' => 'password', + 'html_attr' => ['autocomplete' => 'new-password'], + 'title' => __('Plesk API Key', 'ultimate-multisite'), + 'desc' => __('Generate an API key in Plesk under Tools & Settings → API. Optional if using username/password authentication.', 'ultimate-multisite'), + 'placeholder' => __('Your API key', 'ultimate-multisite'), + ], + 'WU_PLESK_USERNAME' => [ + 'title' => __('Plesk Username', 'ultimate-multisite'), + 'desc' => __('Plesk admin username. Only required if authenticating with a password instead of an API key.', 'ultimate-multisite'), + 'placeholder' => __('e.g. admin', 'ultimate-multisite'), + ], + 'WU_PLESK_PASSWORD' => [ + 'type' => 'password', + 'html_attr' => ['autocomplete' => 'new-password'], + 'title' => __('Plesk Password', 'ultimate-multisite'), + 'desc' => __('Plesk admin password. Optional if using API key authentication.', 'ultimate-multisite'), + 'placeholder' => __('Your password', 'ultimate-multisite'), + ], + 'WU_PLESK_DOMAIN' => [ + 'title' => __('Base Domain', 'ultimate-multisite'), + 'desc' => __('The domain in Plesk that your WordPress multisite is served from. Aliases will be attached to this domain.', 'ultimate-multisite'), + 'placeholder' => __('e.g. network.example.com', 'ultimate-multisite'), + ], + ]; + } + + /** + * Sends a request to the Plesk REST API v2. + * + * Supports API key authentication (preferred) or HTTP Basic Auth as a fallback. + * + * @since 2.5.0 + * + * @param string $endpoint API endpoint (e.g. /api/v2/server). + * @param string $method HTTP method (GET, POST, DELETE, etc.). + * @param array|string $data Request body data (for POST/PUT/PATCH). + * @return array|\WP_Error + */ + public function send_plesk_api_request(string $endpoint, string $method = 'GET', $data = []) { + + $host = $this->get_credential('WU_PLESK_HOST'); + + if (empty($host)) { + wu_log_add('integration-plesk', 'WU_PLESK_HOST not defined or empty'); + + return new \WP_Error('wu_plesk_no_host', __('Missing WU_PLESK_HOST', 'ultimate-multisite')); + } + + $port = $this->get_credential('WU_PLESK_PORT') ?: '8443'; + $api_url = sprintf('https://%s:%s%s', $host, $port, $endpoint); + + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'User-Agent' => 'WP-Ultimo-Plesk-Integration/2.0', + ]; + + // Auth: prefer API key, fall back to Basic Auth + $api_key = $this->get_credential('WU_PLESK_API_KEY'); + $username = $this->get_credential('WU_PLESK_USERNAME'); + $password = $this->get_credential('WU_PLESK_PASSWORD'); + + if (! empty($api_key)) { + $headers['X-API-Key'] = $api_key; + } elseif (! empty($username) && ! empty($password)) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $headers['Authorization'] = 'Basic ' . base64_encode($username . ':' . $password); + } else { + wu_log_add('integration-plesk', 'No authentication credentials configured (need API key or username/password)'); + + return new \WP_Error('wu_plesk_no_auth', __('Missing Plesk authentication credentials', 'ultimate-multisite')); + } + + $args = [ + 'method' => $method, + 'timeout' => 45, + 'headers' => $headers, + ]; + + if (in_array($method, ['POST', 'PUT', 'PATCH'], true) && ! empty($data)) { + $args['body'] = wp_json_encode($data); + } + + wu_log_add('integration-plesk', sprintf('Making %s request to: %s', $method, $api_url)); + + if (! empty($data)) { + wu_log_add('integration-plesk', sprintf('Request data: %s', wp_json_encode($data))); + } + + $response = wp_remote_request($api_url, $args); + + if (is_wp_error($response)) { + wu_log_add('integration-plesk', sprintf('API request failed: %s', $response->get_error_message())); + + return $response; + } + + $response_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + + wu_log_add('integration-plesk', sprintf('API response code: %d, body: %s', $response_code, $response_body)); + + if ($response_code >= 200 && $response_code < 300) { + if (empty($response_body)) { + return ['success' => true]; + } + + $body = json_decode($response_body, true); + + if (json_last_error() === JSON_ERROR_NONE) { + return $body; + } + + // CLI gateway may return plain text on success + return [ + 'success' => true, + 'output' => $response_body, + ]; + } + + $error_data = [ + 'success' => false, + 'error' => sprintf('HTTP %d error', $response_code), + 'response_code' => $response_code, + 'response_body' => $response_body, + ]; + + if (! empty($response_body)) { + $error_body = json_decode($response_body, true); + + if (json_last_error() === JSON_ERROR_NONE && isset($error_body['message'])) { + $error_data['error'] = $error_body['message']; + } + } + + return new \WP_Error( + 'wu_plesk_api_error', + $error_data['error'], + $error_data + ); + } +}