From fe5e06b558825d70b4dece3062e85830c000cb5c Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 9 Feb 2026 10:17:48 -0700 Subject: [PATCH 1/2] Add Plesk hosting integration for domain mapping Adds a new Plesk provider that uses the REST API v2 CLI gateway to manage site aliases and subdomains automatically when domains are mapped or removed. Supports API key and Basic Auth, www alias handling, and AutoSSL. Co-Authored-By: Claude Opus 4.6 --- assets/img/hosts/plesk.svg | 18 ++ .../class-integration-registry.php | 2 + .../plesk/class-plesk-domain-mapping.php | 272 ++++++++++++++++++ .../plesk/class-plesk-integration.php | 241 ++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 assets/img/hosts/plesk.svg create mode 100644 inc/integrations/providers/plesk/class-plesk-domain-mapping.php create mode 100644 inc/integrations/providers/plesk/class-plesk-integration.php diff --git a/assets/img/hosts/plesk.svg b/assets/img/hosts/plesk.svg new file mode 100644 index 000000000..89f0c158c --- /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 84cba450b..50f7aff54 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 000000000..8a2a37c22 --- /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 000000000..16d802c61 --- /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 + ); + } +} From 226f9e3e88ec2e437fe1695b92e969bcbd771ec1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 2 Mar 2026 14:59:13 -0700 Subject: [PATCH 2/2] Fix site duplication not preserving requested title After MUCD_Data::copy_data copies the template's options table, the blogname option gets overwritten with the template's value. The existing save/restore mechanism can fail when the WordPress object cache returns stale values (particularly in test environments with transaction rollback). Explicitly set the blogname via update_blog_option after duplication completes so the requested title is always applied. Also use get_blog_option in the test assertion instead of the cached WP_Site property. Co-Authored-By: Claude Opus 4.6 --- inc/helpers/class-site-duplicator.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/inc/helpers/class-site-duplicator.php b/inc/helpers/class-site-duplicator.php index 2e2ffee17..34c4bd05d 100644 --- a/inc/helpers/class-site-duplicator.php +++ b/inc/helpers/class-site-duplicator.php @@ -259,6 +259,12 @@ protected static function process_duplication($args) { wp_cache_flush(); + // Ensure the requested title is applied after duplication, since the + // table copy may overwrite the blogname option set during site creation. + if (! empty($args->title)) { + update_blog_option($args->to_site_id, 'blogname', $args->title); + } + /** * Allow developers to hook after a site duplication happens. *