diff --git a/assets/js/dns-management.js b/assets/js/dns-management.js new file mode 100644 index 00000000..46fa7ed8 --- /dev/null +++ b/assets/js/dns-management.js @@ -0,0 +1,337 @@ +/* global jQuery, ajaxurl, wu_dns_config */ +/** + * DNS Management Vue.js Component + * + * Handles DNS record display and management in the Ultimate Multisite UI. + * + * @since 2.3.0 + */ +(function($) { + 'use strict'; + + /** + * Initialize DNS Management when DOM is ready. + */ + $(document).ready(function() { + initDNSManagement(); + }); + + /** + * Initialize the DNS Management Vue instance. + */ + function initDNSManagement() { + const container = document.getElementById('wu-dns-records-table'); + + if (!container || typeof Vue === 'undefined') { + return; + } + + // Check if Vue instance already exists + if (container.__vue__) { + return; + } + + window.WU_DNS_Management = new Vue({ + el: '#wu-dns-records-table', + data: { + loading: true, + error: null, + records: [], + readonly: false, + domain: '', + domainId: '', + canManage: false, + provider: '', + recordTypes: ['A', 'AAAA', 'CNAME', 'MX', 'TXT'], + selectedRecords: [], + }, + + computed: { + hasRecords: function() { + return this.records && this.records.length > 0; + }, + + sortedRecords: function() { + if (!this.records) { + return []; + } + + // Sort by type, then by name + return [...this.records].sort(function(a, b) { + if (a.type !== b.type) { + return a.type.localeCompare(b.type); + } + return a.name.localeCompare(b.name); + }); + }, + }, + + mounted: function() { + const el = this.$el; + + this.domain = el.dataset.domain || ''; + this.domainId = el.dataset.domainId || ''; + this.canManage = el.dataset.canManage === 'true'; + + if (this.domain) { + this.loadRecords(); + } + }, + + methods: { + /** + * Load DNS records from the server. + */ + loadRecords: function() { + const self = this; + + this.loading = true; + this.error = null; + + $.ajax({ + url: ajaxurl, + method: 'POST', + data: { + action: 'wu_get_dns_records_for_domain', + nonce: wu_dns_config.nonce, + domain: this.domain, + }, + success: function(response) { + self.loading = false; + + if (response.success) { + self.records = response.data.records || []; + self.readonly = response.data.readonly || false; + self.provider = response.data.provider || ''; + + if (response.data.record_types) { + self.recordTypes = response.data.record_types; + } + + if (response.data.message && self.readonly) { + self.error = response.data.message; + } + } else { + self.error = response.data?.message || 'Failed to load DNS records.'; + } + }, + error: function(xhr, status, errorMsg) { + self.loading = false; + self.error = 'Network error: ' + errorMsg; + }, + }); + }, + + /** + * Refresh the records list. + */ + refresh: function() { + this.loadRecords(); + }, + + /** + * Get CSS class for record type badge. + * + * @param {string} type The record type. + * @return {string} CSS classes. + */ + getTypeClass: function(type) { + const classes = { + 'A': 'wu-bg-blue-100 wu-text-blue-800', + 'AAAA': 'wu-bg-purple-100 wu-text-purple-800', + 'CNAME': 'wu-bg-green-100 wu-text-green-800', + 'MX': 'wu-bg-orange-100 wu-text-orange-800', + 'TXT': 'wu-bg-gray-100 wu-text-gray-800', + }; + + return classes[type] || 'wu-bg-gray-100 wu-text-gray-800'; + }, + + /** + * Format TTL value for display. + * + * @param {number} seconds TTL in seconds. + * @return {string} Formatted TTL. + */ + formatTTL: function(seconds) { + if (seconds === 1) { + return 'Auto'; + } + + if (seconds < 60) { + return seconds + 's'; + } + + if (seconds < 3600) { + return Math.floor(seconds / 60) + 'm'; + } + + if (seconds < 86400) { + return Math.floor(seconds / 3600) + 'h'; + } + + return Math.floor(seconds / 86400) + 'd'; + }, + + /** + * Truncate content for display. + * + * @param {string} content The content to truncate. + * @param {number} maxLength Maximum length. + * @return {string} Truncated content. + */ + truncateContent: function(content, maxLength) { + maxLength = maxLength || 40; + + if (!content || content.length <= maxLength) { + return content; + } + + return content.substring(0, maxLength) + '...'; + }, + + /** + * Get the edit URL for a record. + * + * @param {Object} record The record object. + * @return {string} Edit URL. + */ + getEditUrl: function(record) { + if (!wu_dns_config.edit_url) { + return '#'; + } + + return wu_dns_config.edit_url + + '&record_id=' + encodeURIComponent(record.id) + + '&domain_id=' + encodeURIComponent(this.domainId); + }, + + /** + * Get the delete URL for a record. + * + * @param {Object} record The record object. + * @return {string} Delete URL. + */ + getDeleteUrl: function(record) { + if (!wu_dns_config.delete_url) { + return '#'; + } + + return wu_dns_config.delete_url + + '&record_id=' + encodeURIComponent(record.id) + + '&domain_id=' + encodeURIComponent(this.domainId); + }, + + /** + * Toggle record selection. + * + * @param {string} recordId The record ID. + */ + toggleSelection: function(recordId) { + const index = this.selectedRecords.indexOf(recordId); + + if (index > -1) { + this.selectedRecords.splice(index, 1); + } else { + this.selectedRecords.push(recordId); + } + }, + + /** + * Check if a record is selected. + * + * @param {string} recordId The record ID. + * @return {boolean} True if selected. + */ + isSelected: function(recordId) { + return this.selectedRecords.indexOf(recordId) > -1; + }, + + /** + * Select all records. + */ + selectAll: function() { + const self = this; + + this.selectedRecords = this.records.map(function(record) { + return record.id; + }); + }, + + /** + * Deselect all records. + */ + deselectAll: function() { + this.selectedRecords = []; + }, + + /** + * Delete selected records (admin bulk operation). + */ + deleteSelected: function() { + if (!this.selectedRecords.length) { + return; + } + + if (!confirm('Are you sure you want to delete ' + this.selectedRecords.length + ' selected records?')) { + return; + } + + const self = this; + + $.ajax({ + url: ajaxurl, + method: 'POST', + data: { + action: 'wu_bulk_dns_operations', + nonce: wu_dns_config.nonce, + domain: this.domain, + operation: 'delete', + records: this.selectedRecords, + }, + success: function(response) { + if (response.success) { + self.selectedRecords = []; + self.loadRecords(); + } else { + alert('Error: ' + (response.data?.message || 'Failed to delete records.')); + } + }, + error: function() { + alert('Network error occurred.'); + }, + }); + }, + + /** + * Get proxied status display. + * + * @param {Object} record The record object. + * @return {string} Proxied status HTML. + */ + getProxiedStatus: function(record) { + if (this.provider !== 'cloudflare') { + return ''; + } + + if (record.proxied) { + return '☁'; + } + + return '☁'; + }, + }, + }); + } + + /** + * Reinitialize DNS management when modal content is loaded. + * This handles wubox modal scenarios. + */ + $(document).on('wubox-load', function() { + setTimeout(function() { + initDNSManagement(); + }, 100); + }); + +})(jQuery); diff --git a/inc/admin-pages/class-domain-edit-admin-page.php b/inc/admin-pages/class-domain-edit-admin-page.php index b71d2b7e..02872c0b 100644 --- a/inc/admin-pages/class-domain-edit-admin-page.php +++ b/inc/admin-pages/class-domain-edit-admin-page.php @@ -93,6 +93,33 @@ public function register_forms(): void { add_filter('wu_form_fields_delete_domain_modal', [$this, 'domain_extra_delete_fields'], 10, 2); add_action('wu_after_delete_domain_modal', [$this, 'domain_after_delete_actions']); + + /* + * Register admin DNS management forms. + */ + wu_register_form( + 'admin_add_dns_record', + [ + 'render' => [$this, 'render_admin_add_dns_record_modal'], + 'handler' => [$this, 'handle_admin_add_dns_record_modal'], + ] + ); + + wu_register_form( + 'admin_edit_dns_record', + [ + 'render' => [$this, 'render_admin_edit_dns_record_modal'], + 'handler' => [$this, 'handle_admin_edit_dns_record_modal'], + ] + ); + + wu_register_form( + 'admin_delete_dns_record', + [ + 'render' => [$this, 'render_admin_delete_dns_record_modal'], + 'handler' => [$this, 'handle_admin_delete_dns_record_modal'], + ] + ); } /** * Registers the necessary scripts and styles for this admin page. @@ -103,6 +130,9 @@ public function register_forms(): void { public function register_scripts(): void { parent::register_scripts(); + $domain_id = $this->get_object()->get_id(); + + // Enqueue read-only DNS table for PHP DNS lookup fallback wp_enqueue_script( 'wu-dns-table', wu_get_asset('dns-table.js', 'js'), @@ -113,6 +143,17 @@ public function register_scripts(): void { ] ); + // Enqueue DNS management script for provider-based management + wp_enqueue_script( + 'wu-dns-management', + wu_get_asset('dns-management.js', 'js'), + ['jquery', 'wu-vue'], + \WP_Ultimo::VERSION, + [ + 'in_footer' => true, + ] + ); + wp_enqueue_script( 'wu-domain-logs', wu_get_asset('domain-logs.js', 'js'), @@ -123,6 +164,7 @@ public function register_scripts(): void { ] ); + // Config for read-only DNS lookup wp_localize_script( 'wu-dns-table', 'wu_dns_table_config', @@ -131,6 +173,18 @@ public function register_scripts(): void { ] ); + // Config for DNS management (provider-based) + wp_localize_script( + 'wu-dns-management', + 'wu_dns_config', + [ + 'nonce' => wp_create_nonce('wu_dns_nonce'), + 'add_url' => wu_get_form_url('admin_add_dns_record', ['domain_id' => $domain_id]), + 'edit_url' => wu_get_form_url('admin_edit_dns_record', ['domain_id' => $domain_id]), + 'delete_url' => wu_get_form_url('admin_delete_dns_record', ['domain_id' => $domain_id]), + ] + ); + wp_localize_script( 'wu-domain-logs', 'wu_domain_logs', @@ -419,10 +473,20 @@ public function register_widgets(): void { */ public function render_dns_widget(): void { + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + $domain = $this->get_object(); + $domain_id = $domain->get_id(); + wu_get_template( - 'domain/dns-table', + 'domain/admin-dns-management', [ - 'domain' => $this->get_object(), + 'domain' => $domain, + 'domain_id' => $domain_id, + 'can_manage' => true, // Admins can always manage DNS + 'has_provider' => (bool) $dns_provider, + 'provider_name' => $dns_provider ? $dns_provider->get_title() : '', + 'add_url' => wu_get_form_url('admin_add_dns_record', ['domain_id' => $domain_id]), ] ); } @@ -595,4 +659,274 @@ public function handle_save(): void { parent::handle_save(); } + + /** + * Renders the admin add DNS record modal. + * + * @since 2.3.0 + * @return void + */ + public function render_admin_add_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_die(esc_html__('Domain not found.', 'ultimate-multisite')); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + wu_get_template( + 'domain/dns-record-form', + [ + 'domain_id' => $domain_id, + 'domain_name' => $domain->get_domain(), + 'mode' => 'add', + 'record' => [], + 'allowed_types' => $dns_provider ? $dns_provider->get_supported_record_types() : ['A', 'AAAA', 'CNAME', 'MX', 'TXT'], + 'show_proxied' => $dns_provider && method_exists($dns_provider, 'get_id') && $dns_provider->get_id() === 'cloudflare', + ] + ); + } + + /** + * Handles the admin add DNS record modal submission. + * + * @since 2.3.0 + * @return void + */ + public function handle_admin_add_dns_record_modal(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain_id = wu_request('domain_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_send_json_error(['message' => __('Domain not found.', 'ultimate-multisite')]); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + if ( ! $dns_provider) { + wp_send_json_error(['message' => __('No DNS provider configured.', 'ultimate-multisite')]); + } + + $record_data = wu_request('record', []); + + $result = $dns_provider->create_dns_record($domain->get_domain(), $record_data); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success( + [ + 'message' => __('DNS record created successfully.', 'ultimate-multisite'), + 'record' => $result, + ] + ); + } + + /** + * Renders the admin edit DNS record modal. + * + * @since 2.3.0 + * @return void + */ + public function render_admin_edit_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_die(esc_html__('Domain not found.', 'ultimate-multisite')); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + // Get the record data from the provider + $records = $dns_provider ? $dns_provider->get_dns_records($domain->get_domain()) : []; + $record = []; + + if ( ! is_wp_error($records)) { + foreach ($records as $r) { + if ((string) $r->get_id() === (string) $record_id) { + $record = $r->to_array(); + break; + } + } + } + + wu_get_template( + 'domain/dns-record-form', + [ + 'domain_id' => $domain_id, + 'domain_name' => $domain->get_domain(), + 'mode' => 'edit', + 'record' => $record, + 'allowed_types' => $dns_provider ? $dns_provider->get_supported_record_types() : ['A', 'AAAA', 'CNAME', 'MX', 'TXT'], + 'show_proxied' => $dns_provider && method_exists($dns_provider, 'get_id') && $dns_provider->get_id() === 'cloudflare', + ] + ); + } + + /** + * Handles the admin edit DNS record modal submission. + * + * @since 2.3.0 + * @return void + */ + public function handle_admin_edit_dns_record_modal(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_send_json_error(['message' => __('Domain not found.', 'ultimate-multisite')]); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + if ( ! $dns_provider) { + wp_send_json_error(['message' => __('No DNS provider configured.', 'ultimate-multisite')]); + } + + $record_data = wu_request('record', []); + + $result = $dns_provider->update_dns_record($domain->get_domain(), $record_id, $record_data); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success( + [ + 'message' => __('DNS record updated successfully.', 'ultimate-multisite'), + 'record' => $result, + ] + ); + } + + /** + * Renders the admin delete DNS record modal. + * + * @since 2.3.0 + * @return void + */ + public function render_admin_delete_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_die(esc_html__('Domain not found.', 'ultimate-multisite')); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + // Get the record data from the provider + $records = $dns_provider ? $dns_provider->get_dns_records($domain->get_domain()) : []; + $record_name = $record_id; + + if ( ! is_wp_error($records)) { + foreach ($records as $r) { + if ((string) $r->get_id() === (string) $record_id) { + $record_name = $r->get_type() . ' - ' . $r->get_name(); + break; + } + } + } + + $fields = [ + 'confirm_message' => [ + 'type' => 'note', + 'desc' => sprintf( + /* translators: %s: Record name/identifier */ + __('Are you sure you want to delete the DNS record %s? This action cannot be undone.', 'ultimate-multisite'), + esc_html($record_name) + ), + ], + 'domain_id' => [ + 'type' => 'hidden', + 'value' => $domain_id, + ], + 'record_id' => [ + 'type' => 'hidden', + 'value' => $record_id, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Delete Record', 'ultimate-multisite'), + 'value' => 'delete', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'admin_delete_dns_record', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'delete_dns_record', + 'data-state' => wu_convert_to_state([]), + ], + ] + ); + + $form->render(); + } + + /** + * Handles the admin delete DNS record modal submission. + * + * @since 2.3.0 + * @return void + */ + public function handle_admin_delete_dns_record_modal(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if ( ! $domain) { + wp_send_json_error(['message' => __('Domain not found.', 'ultimate-multisite')]); + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + + if ( ! $dns_provider) { + wp_send_json_error(['message' => __('No DNS provider configured.', 'ultimate-multisite')]); + } + + $result = $dns_provider->delete_dns_record($domain->get_domain(), $record_id); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success( + [ + 'message' => __('DNS record deleted successfully.', 'ultimate-multisite'), + ] + ); + } } diff --git a/inc/integrations/host-providers/class-base-host-provider.php b/inc/integrations/host-providers/class-base-host-provider.php index 51616542..906c4022 100644 --- a/inc/integrations/host-providers/class-base-host-provider.php +++ b/inc/integrations/host-providers/class-base-host-provider.php @@ -16,8 +16,12 @@ /** * This base class should be extended to implement new host integrations for SSL and domains. + * + * Implements DNS_Provider_Interface to provide default DNS management functionality. + * Providers that support DNS should add 'dns-management' to their $supports array + * and override the DNS methods. */ -abstract class Base_Host_Provider { +abstract class Base_Host_Provider implements DNS_Provider_Interface { /** * Holds the id of the integration. @@ -653,4 +657,174 @@ public function get_logo() { return ''; } + + /** + * Check if this provider supports DNS record management. + * + * @since 2.3.0 + * @return bool + */ + public function supports_dns_management(): bool { + + return $this->supports('dns-management'); + } + + /** + * Get DNS records for a domain. + * + * Providers that support DNS management should override this method. + * + * @since 2.3.0 + * + * @param string $domain The domain to query. + * @return array|WP_Error Array of DNS_Record objects or WP_Error. + */ + public function get_dns_records(string $domain) { + + return new \WP_Error( + 'dns-not-supported', + __('DNS record management is not supported by this hosting provider.', 'ultimate-multisite') + ); + } + + /** + * Create a DNS record for a domain. + * + * Providers that support DNS management should override this method. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param array $record Record data (type, name, content, ttl, priority). + * @return array|WP_Error Created record data or WP_Error. + */ + public function create_dns_record(string $domain, array $record) { + + return new \WP_Error( + 'dns-not-supported', + __('DNS record management is not supported by this hosting provider.', 'ultimate-multisite') + ); + } + + /** + * Update a DNS record for a domain. + * + * Providers that support DNS management should override this method. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @param array $record Updated record data. + * @return array|WP_Error Updated record data or WP_Error. + */ + public function update_dns_record(string $domain, string $record_id, array $record) { + + return new \WP_Error( + 'dns-not-supported', + __('DNS record management is not supported by this hosting provider.', 'ultimate-multisite') + ); + } + + /** + * Delete a DNS record for a domain. + * + * Providers that support DNS management should override this method. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @return bool|WP_Error True on success or WP_Error. + */ + public function delete_dns_record(string $domain, string $record_id) { + + return new \WP_Error( + 'dns-not-supported', + __('DNS record management is not supported by this hosting provider.', 'ultimate-multisite') + ); + } + + /** + * Get the DNS record types supported by this provider. + * + * @since 2.3.0 + * + * @return array Array of supported record types. + */ + public function get_supported_record_types(): array { + + return ['A', 'AAAA', 'CNAME', 'MX', 'TXT']; + } + + /** + * Get the zone/domain identifier for DNS operations. + * + * Some providers require a zone ID rather than domain name. + * Override this method to implement zone ID lookup. + * + * @since 2.3.0 + * + * @param string $domain The domain name. + * @return string|null Zone identifier or null if not found. + */ + public function get_zone_id(string $domain): ?string { + + return null; + } + + /** + * Check if DNS management is enabled for this provider. + * + * This checks both support and whether the admin has enabled it. + * + * @since 2.3.0 + * + * @return bool + */ + public function is_dns_enabled(): bool { + + if (! $this->supports_dns_management()) { + return false; + } + + // Check if DNS is enabled for this specific provider + $dns_enabled = get_network_option(null, 'wu_dns_integrations_enabled', []); + + return ! empty($dns_enabled[ $this->get_id() ]); + } + + /** + * Enable DNS management for this provider. + * + * @since 2.3.0 + * + * @return bool + */ + public function enable_dns(): bool { + + if (! $this->supports_dns_management()) { + return false; + } + + $dns_enabled = get_network_option(null, 'wu_dns_integrations_enabled', []); + $dns_enabled[ $this->get_id() ] = true; + + return update_network_option(null, 'wu_dns_integrations_enabled', $dns_enabled); + } + + /** + * Disable DNS management for this provider. + * + * @since 2.3.0 + * + * @return bool + */ + public function disable_dns(): bool { + + $dns_enabled = get_network_option(null, 'wu_dns_integrations_enabled', []); + $dns_enabled[ $this->get_id() ] = false; + + return update_network_option(null, 'wu_dns_integrations_enabled', $dns_enabled); + } } diff --git a/inc/integrations/host-providers/class-cloudflare-host-provider.php b/inc/integrations/host-providers/class-cloudflare-host-provider.php index 4daa4397..b827e864 100644 --- a/inc/integrations/host-providers/class-cloudflare-host-provider.php +++ b/inc/integrations/host-providers/class-cloudflare-host-provider.php @@ -54,6 +54,7 @@ class Cloudflare_Host_Provider extends Base_Host_Provider { */ protected $supports = [ 'autossl', + 'dns-management', ]; /** @@ -367,6 +368,389 @@ public function on_remove_subdomain($subdomain, $site_id): void { } } + /** + * Get DNS records for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain to query. + * @return array|\WP_Error Array of DNS_Record objects or WP_Error. + */ + public function get_dns_records(string $domain) { + + $zone_id = $this->get_zone_id($domain); + + if (! $zone_id) { + return new \WP_Error( + 'zone-not-found', + sprintf( + /* translators: %s: domain name */ + __('Could not find Cloudflare zone for domain: %s', 'ultimate-multisite'), + $domain + ) + ); + } + + $supported_types = implode(',', $this->get_supported_record_types()); + + $response = $this->cloudflare_api_call( + "client/v4/zones/{$zone_id}/dns_records", + 'GET', + [ + 'per_page' => 100, + 'type' => $supported_types, + ] + ); + + if (is_wp_error($response)) { + return $response; + } + + if (! isset($response->result) || ! is_array($response->result)) { + return new \WP_Error( + 'invalid-response', + __('Invalid response from Cloudflare API.', 'ultimate-multisite') + ); + } + + $records = []; + + foreach ($response->result as $record) { + $records[] = DNS_Record::from_provider( + [ + 'id' => $record->id, + 'type' => $record->type, + 'name' => $record->name, + 'content' => $record->content, + 'ttl' => $record->ttl, + 'priority' => $record->priority ?? null, + 'proxied' => $record->proxied ?? false, + 'zone_id' => $record->zone_id ?? $zone_id, + 'zone_name' => $record->zone_name ?? '', + ], + 'cloudflare' + ); + } + + return $records; + } + + /** + * Create a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param array $record Record data (type, name, content, ttl, priority, proxied). + * @return array|\WP_Error Created record data or WP_Error. + */ + public function create_dns_record(string $domain, array $record) { + + $zone_id = $this->get_zone_id($domain); + + if (! $zone_id) { + return new \WP_Error( + 'zone-not-found', + sprintf( + /* translators: %s: domain name */ + __('Could not find Cloudflare zone for domain: %s', 'ultimate-multisite'), + $domain + ) + ); + } + + $data = [ + 'type' => strtoupper($record['type']), + 'name' => $record['name'], + 'content' => $record['content'], + 'ttl' => (int) ($record['ttl'] ?? 1), // 1 = auto + 'proxied' => ! empty($record['proxied']), + ]; + + // Add priority for MX records + if ('MX' === $record['type'] && isset($record['priority'])) { + $data['priority'] = (int) $record['priority']; + } + + // Cloudflare doesn't support proxied for certain record types + if (in_array($data['type'], ['MX', 'TXT'], true)) { + unset($data['proxied']); + } + + $response = $this->cloudflare_api_call( + "client/v4/zones/{$zone_id}/dns_records", + 'POST', + $data + ); + + if (is_wp_error($response)) { + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Failed to create DNS record for %s: %s', + $domain, + $response->get_error_message() + ), + LogLevel::ERROR + ); + + return $response; + } + + if (! isset($response->result)) { + return new \WP_Error( + 'invalid-response', + __('Invalid response from Cloudflare API.', 'ultimate-multisite') + ); + } + + $created = $response->result; + + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Created DNS record: %s %s -> %s (ID: %s)', + $created->type, + $created->name, + $created->content, + $created->id + ) + ); + + return DNS_Record::from_provider( + [ + 'id' => $created->id, + 'type' => $created->type, + 'name' => $created->name, + 'content' => $created->content, + 'ttl' => $created->ttl, + 'priority' => $created->priority ?? null, + 'proxied' => $created->proxied ?? false, + 'zone_id' => $zone_id, + ], + 'cloudflare' + )->to_array(); + } + + /** + * Update a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @param array $record Updated record data. + * @return array|\WP_Error Updated record data or WP_Error. + */ + public function update_dns_record(string $domain, string $record_id, array $record) { + + $zone_id = $this->get_zone_id($domain); + + if (! $zone_id) { + return new \WP_Error( + 'zone-not-found', + sprintf( + /* translators: %s: domain name */ + __('Could not find Cloudflare zone for domain: %s', 'ultimate-multisite'), + $domain + ) + ); + } + + $data = [ + 'type' => strtoupper($record['type']), + 'name' => $record['name'], + 'content' => $record['content'], + 'ttl' => (int) ($record['ttl'] ?? 1), + 'proxied' => ! empty($record['proxied']), + ]; + + // Add priority for MX records + if ('MX' === $record['type'] && isset($record['priority'])) { + $data['priority'] = (int) $record['priority']; + } + + // Cloudflare doesn't support proxied for certain record types + if (in_array($data['type'], ['MX', 'TXT'], true)) { + unset($data['proxied']); + } + + $response = $this->cloudflare_api_call( + "client/v4/zones/{$zone_id}/dns_records/{$record_id}", + 'PATCH', + $data + ); + + if (is_wp_error($response)) { + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Failed to update DNS record %s for %s: %s', + $record_id, + $domain, + $response->get_error_message() + ), + LogLevel::ERROR + ); + + return $response; + } + + if (! isset($response->result)) { + return new \WP_Error( + 'invalid-response', + __('Invalid response from Cloudflare API.', 'ultimate-multisite') + ); + } + + $updated = $response->result; + + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Updated DNS record: %s %s -> %s (ID: %s)', + $updated->type, + $updated->name, + $updated->content, + $updated->id + ) + ); + + return DNS_Record::from_provider( + [ + 'id' => $updated->id, + 'type' => $updated->type, + 'name' => $updated->name, + 'content' => $updated->content, + 'ttl' => $updated->ttl, + 'priority' => $updated->priority ?? null, + 'proxied' => $updated->proxied ?? false, + 'zone_id' => $zone_id, + ], + 'cloudflare' + )->to_array(); + } + + /** + * Delete a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @return bool|\WP_Error True on success or WP_Error. + */ + public function delete_dns_record(string $domain, string $record_id) { + + $zone_id = $this->get_zone_id($domain); + + if (! $zone_id) { + return new \WP_Error( + 'zone-not-found', + sprintf( + /* translators: %s: domain name */ + __('Could not find Cloudflare zone for domain: %s', 'ultimate-multisite'), + $domain + ) + ); + } + + $response = $this->cloudflare_api_call( + "client/v4/zones/{$zone_id}/dns_records/{$record_id}", + 'DELETE' + ); + + if (is_wp_error($response)) { + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Failed to delete DNS record %s for %s: %s', + $record_id, + $domain, + $response->get_error_message() + ), + LogLevel::ERROR + ); + + return $response; + } + + wu_log_add( + 'integration-cloudflare', + sprintf( + 'Deleted DNS record: ID %s for domain %s', + $record_id, + $domain + ) + ); + + return true; + } + + /** + * Get the zone ID for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain name. + * @return string|null Zone ID or null if not found. + */ + public function get_zone_id(string $domain): ?string { + + // Try configured zone first + $default_zone = defined('WU_CLOUDFLARE_ZONE_ID') && WU_CLOUDFLARE_ZONE_ID ? WU_CLOUDFLARE_ZONE_ID : null; + + // Extract root domain for zone lookup + $root_domain = $this->extract_root_domain($domain); + + // Try to find zone by domain name + $response = $this->cloudflare_api_call( + 'client/v4/zones', + 'GET', + [ + 'name' => $root_domain, + 'status' => 'active', + ] + ); + + if (! is_wp_error($response) && ! empty($response->result)) { + return $response->result[0]->id; + } + + // Fall back to configured zone + return $default_zone; + } + + /** + * Extract the root domain from a full domain name. + * + * @since 2.3.0 + * + * @param string $domain The full domain name. + * @return string The root domain. + */ + protected function extract_root_domain(string $domain): string { + + $parts = explode('.', $domain); + + // Known multi-part TLDs + $multi_tlds = ['.co.uk', '.com.au', '.co.nz', '.com.br', '.co.in', '.org.uk', '.net.au']; + + foreach ($multi_tlds as $tld) { + if (str_ends_with($domain, $tld)) { + // Return last 3 parts for multi-part TLD + return implode('.', array_slice($parts, -3)); + } + } + + // Return last 2 parts for standard TLD + if (count($parts) >= 2) { + return implode('.', array_slice($parts, -2)); + } + + return $domain; + } + /** * Sends an API call to Cloudflare. * diff --git a/inc/integrations/host-providers/class-cpanel-host-provider.php b/inc/integrations/host-providers/class-cpanel-host-provider.php index a9e28f22..e65857d8 100644 --- a/inc/integrations/host-providers/class-cpanel-host-provider.php +++ b/inc/integrations/host-providers/class-cpanel-host-provider.php @@ -55,6 +55,7 @@ class CPanel_Host_Provider extends Base_Host_Provider { protected $supports = [ 'autossl', 'no-instructions', + 'dns-management', ]; /** @@ -307,6 +308,350 @@ public function log_calls($results) { wu_log_add('integration-cpanel', $results->cpanelresult->data[0]->reason); } + /** + * Get DNS records for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain to query. + * @return array|\WP_Error Array of DNS_Record objects or WP_Error. + */ + public function get_dns_records(string $domain) { + + // Extract the zone name (root domain) + $zone = $this->extract_zone_name($domain); + + $result = $this->load_api()->uapi( + 'DNS', + 'parse_zone', + ['zone' => $zone] + ); + + if (! $result || isset($result->errors) || ! isset($result->result->data)) { + $error_message = isset($result->errors) && is_array($result->errors) + ? implode(', ', $result->errors) + : __('Failed to fetch DNS records from cPanel.', 'ultimate-multisite'); + + wu_log_add('integration-cpanel', 'DNS fetch failed: ' . $error_message, LogLevel::ERROR); + + return new \WP_Error('dns-error', $error_message); + } + + $records = []; + $supported_types = $this->get_supported_record_types(); + + foreach ($result->result->data as $record) { + // Only include supported record types + if (! isset($record->type) || ! in_array($record->type, $supported_types, true)) { + continue; + } + + // Get content based on record type + $content = ''; + switch ($record->type) { + case 'A': + case 'AAAA': + $content = $record->address ?? ''; + break; + case 'CNAME': + $content = $record->cname ?? ''; + break; + case 'MX': + $content = $record->exchange ?? ''; + break; + case 'TXT': + $content = $record->txtdata ?? ''; + // Remove surrounding quotes if present + $content = trim($content, '"'); + break; + } + + $records[] = DNS_Record::from_provider( + [ + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- cPanel API property + 'line_index' => $record->line_index ?? $record->Line ?? '', + 'type' => $record->type, + 'name' => rtrim($record->name ?? '', '.'), + 'address' => $record->address ?? null, + 'cname' => $record->cname ?? null, + 'exchange' => $record->exchange ?? null, + 'txtdata' => $record->txtdata ?? null, + 'ttl' => $record->ttl ?? 14400, + 'preference' => $record->preference ?? null, + ], + 'cpanel' + ); + } + + return $records; + } + + /** + * Create a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param array $record Record data (type, name, content, ttl, priority). + * @return array|\WP_Error Created record data or WP_Error. + */ + public function create_dns_record(string $domain, array $record) { + + $zone = $this->extract_zone_name($domain); + + $params = [ + 'zone' => $zone, + 'name' => $this->format_record_name($record['name'], $zone), + 'type' => strtoupper($record['type']), + 'ttl' => (int) ($record['ttl'] ?? 14400), + ]; + + // Add type-specific parameters + switch (strtoupper($record['type'])) { + case 'A': + case 'AAAA': + $params['address'] = $record['content']; + break; + case 'CNAME': + $params['cname'] = $this->ensure_trailing_dot($record['content']); + break; + case 'MX': + $params['exchange'] = $this->ensure_trailing_dot($record['content']); + $params['preference'] = (int) ($record['priority'] ?? 10); + break; + case 'TXT': + $params['txtdata'] = $record['content']; + break; + default: + return new \WP_Error( + 'unsupported-type', + /* translators: %s: record type */ + sprintf(__('Unsupported record type: %s', 'ultimate-multisite'), $record['type']) + ); + } + + $result = $this->load_api()->uapi('DNS', 'add_zone_record', $params); + + if (! $result || isset($result->errors)) { + $error_message = isset($result->errors) && is_array($result->errors) + ? implode(', ', $result->errors) + : __('Failed to create DNS record.', 'ultimate-multisite'); + + wu_log_add('integration-cpanel', 'DNS create failed: ' . $error_message, LogLevel::ERROR); + + return new \WP_Error('dns-create-error', $error_message); + } + + wu_log_add( + 'integration-cpanel', + sprintf( + 'Created DNS record: %s %s -> %s', + $record['type'], + $record['name'], + $record['content'] + ) + ); + + // Return the record data with generated ID + return [ + 'id' => $result->result->data->newserial ?? time(), + 'type' => $record['type'], + 'name' => $record['name'], + 'content' => $record['content'], + 'ttl' => $params['ttl'], + 'priority' => $record['priority'] ?? null, + ]; + } + + /** + * Update a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier (line index). + * @param array $record Updated record data. + * @return array|\WP_Error Updated record data or WP_Error. + */ + public function update_dns_record(string $domain, string $record_id, array $record) { + + $zone = $this->extract_zone_name($domain); + + $params = [ + 'zone' => $zone, + 'line' => (int) $record_id, + 'name' => $this->format_record_name($record['name'], $zone), + 'type' => strtoupper($record['type']), + 'ttl' => (int) ($record['ttl'] ?? 14400), + ]; + + // Add type-specific parameters + switch (strtoupper($record['type'])) { + case 'A': + case 'AAAA': + $params['address'] = $record['content']; + break; + case 'CNAME': + $params['cname'] = $this->ensure_trailing_dot($record['content']); + break; + case 'MX': + $params['exchange'] = $this->ensure_trailing_dot($record['content']); + $params['preference'] = (int) ($record['priority'] ?? 10); + break; + case 'TXT': + $params['txtdata'] = $record['content']; + break; + } + + $result = $this->load_api()->uapi('DNS', 'edit_zone_record', $params); + + if (! $result || isset($result->errors)) { + $error_message = isset($result->errors) && is_array($result->errors) + ? implode(', ', $result->errors) + : __('Failed to update DNS record.', 'ultimate-multisite'); + + wu_log_add('integration-cpanel', 'DNS update failed: ' . $error_message, LogLevel::ERROR); + + return new \WP_Error('dns-update-error', $error_message); + } + + wu_log_add( + 'integration-cpanel', + sprintf( + 'Updated DNS record: Line %s - %s %s -> %s', + $record_id, + $record['type'], + $record['name'], + $record['content'] + ) + ); + + return [ + 'id' => $record_id, + 'type' => $record['type'], + 'name' => $record['name'], + 'content' => $record['content'], + 'ttl' => $params['ttl'], + 'priority' => $record['priority'] ?? null, + ]; + } + + /** + * Delete a DNS record for a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier (line index). + * @return bool|\WP_Error True on success or WP_Error. + */ + public function delete_dns_record(string $domain, string $record_id) { + + $zone = $this->extract_zone_name($domain); + + $result = $this->load_api()->uapi( + 'DNS', + 'remove_zone_record', + [ + 'zone' => $zone, + 'line' => (int) $record_id, + ] + ); + + if (! $result || isset($result->errors)) { + $error_message = isset($result->errors) && is_array($result->errors) + ? implode(', ', $result->errors) + : __('Failed to delete DNS record.', 'ultimate-multisite'); + + wu_log_add('integration-cpanel', 'DNS delete failed: ' . $error_message, LogLevel::ERROR); + + return new \WP_Error('dns-delete-error', $error_message); + } + + wu_log_add( + 'integration-cpanel', + sprintf( + 'Deleted DNS record: Line %s from zone %s', + $record_id, + $zone + ) + ); + + return true; + } + + /** + * Extract the zone name (root domain) from a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain name. + * @return string The zone name. + */ + protected function extract_zone_name(string $domain): string { + + $parts = explode('.', $domain); + + // Known multi-part TLDs + $multi_tlds = ['.co.uk', '.com.au', '.co.nz', '.com.br', '.co.in', '.org.uk', '.net.au']; + + foreach ($multi_tlds as $tld) { + if (str_ends_with($domain, $tld)) { + return implode('.', array_slice($parts, -3)); + } + } + + // Return last 2 parts for standard TLD + if (count($parts) >= 2) { + return implode('.', array_slice($parts, -2)); + } + + return $domain; + } + + /** + * Format the record name for cPanel API. + * + * @since 2.3.0 + * + * @param string $name The record name. + * @param string $zone The zone name. + * @return string Formatted name with trailing dot. + */ + protected function format_record_name(string $name, string $zone): string { + + // Handle @ as root domain + if ('@' === $name || '' === $name) { + return $zone . '.'; + } + + // If name already ends with zone, just add trailing dot + if (str_ends_with($name, $zone)) { + return $name . '.'; + } + + // If name ends with dot, it's already FQDN + if (str_ends_with($name, '.')) { + return $name; + } + + // Append zone + return $name . '.' . $zone . '.'; + } + + /** + * Ensure a hostname has a trailing dot. + * + * @since 2.3.0 + * + * @param string $hostname The hostname. + * @return string Hostname with trailing dot. + */ + protected function ensure_trailing_dot(string $hostname): string { + + return str_ends_with($hostname, '.') ? $hostname : $hostname . '.'; + } + /** * Returns the description of this integration. * diff --git a/inc/integrations/host-providers/class-dns-record.php b/inc/integrations/host-providers/class-dns-record.php new file mode 100644 index 00000000..b70d1800 --- /dev/null +++ b/inc/integrations/host-providers/class-dns-record.php @@ -0,0 +1,508 @@ + '1 minute', + 300 => '5 minutes', + 600 => '10 minutes', + 1800 => '30 minutes', + 3600 => '1 hour', + 7200 => '2 hours', + 14400 => '4 hours', + 43200 => '12 hours', + 86400 => '1 day', + 172800 => '2 days', + 604800 => '1 week', + ]; + + /** + * Constructor. + * + * @since 2.3.0 + * + * @param array $data Record data. + */ + public function __construct(array $data) { + + $this->id = (string) ($data['id'] ?? ''); + $this->type = strtoupper($data['type'] ?? 'A'); + $this->name = (string) ($data['name'] ?? ''); + $this->content = (string) ($data['content'] ?? ''); + $this->ttl = (int) ($data['ttl'] ?? 3600); + $this->priority = isset($data['priority']) ? (int) $data['priority'] : null; + $this->proxied = (bool) ($data['proxied'] ?? false); + $this->meta = (array) ($data['meta'] ?? []); + } + + /** + * Convert the record to an array. + * + * @since 2.3.0 + * + * @return array + */ + public function to_array(): array { + + return [ + 'id' => $this->id, + 'type' => $this->type, + 'name' => $this->name, + 'content' => $this->content, + 'ttl' => $this->ttl, + 'priority' => $this->priority, + 'proxied' => $this->proxied, + 'meta' => $this->meta, + ]; + } + + /** + * Get the record type. + * + * @since 2.3.0 + * + * @return string + */ + public function get_type(): string { + + return $this->type; + } + + /** + * Get the record name/host. + * + * @since 2.3.0 + * + * @return string + */ + public function get_name(): string { + + return $this->name; + } + + /** + * Get the full hostname including the domain. + * + * @since 2.3.0 + * + * @param string $domain The base domain. + * @return string + */ + public function get_full_name(string $domain): string { + + if ('@' === $this->name || '' === $this->name || $domain === $this->name) { + return $domain; + } + + // If name already ends with domain, return as-is + if (str_ends_with($this->name, $domain)) { + return $this->name; + } + + return $this->name . '.' . $domain; + } + + /** + * Get the record content/value. + * + * @since 2.3.0 + * + * @return string + */ + public function get_content(): string { + + return $this->content; + } + + /** + * Get the TTL value. + * + * @since 2.3.0 + * + * @return int + */ + public function get_ttl(): int { + + return $this->ttl; + } + + /** + * Get a human-readable TTL string. + * + * @since 2.3.0 + * + * @return string + */ + public function get_ttl_label(): string { + + if (1 === $this->ttl) { + return __('Auto', 'ultimate-multisite'); + } + + if (isset(self::TTL_OPTIONS[ $this->ttl ])) { + return self::TTL_OPTIONS[ $this->ttl ]; + } + + // Format custom TTL values + if ($this->ttl < 60) { + /* translators: %d: number of seconds */ + return sprintf(_n('%d second', '%d seconds', $this->ttl, 'ultimate-multisite'), $this->ttl); + } + + if ($this->ttl < 3600) { + $minutes = floor($this->ttl / 60); + /* translators: %d: number of minutes */ + return sprintf(_n('%d minute', '%d minutes', $minutes, 'ultimate-multisite'), $minutes); + } + + if ($this->ttl < 86400) { + $hours = floor($this->ttl / 3600); + /* translators: %d: number of hours */ + return sprintf(_n('%d hour', '%d hours', $hours, 'ultimate-multisite'), $hours); + } + + $days = floor($this->ttl / 86400); + /* translators: %d: number of days */ + return sprintf(_n('%d day', '%d days', $days, 'ultimate-multisite'), $days); + } + + /** + * Get the priority (for MX records). + * + * @since 2.3.0 + * + * @return int|null + */ + public function get_priority(): ?int { + + return $this->priority; + } + + /** + * Check if the record is proxied (Cloudflare). + * + * @since 2.3.0 + * + * @return bool + */ + public function is_proxied(): bool { + + return $this->proxied; + } + + /** + * Get provider-specific metadata. + * + * @since 2.3.0 + * + * @param string|null $key Optional key to retrieve specific value. + * @return mixed + */ + public function get_meta(?string $key = null) { + + if (null === $key) { + return $this->meta; + } + + return $this->meta[ $key ] ?? null; + } + + /** + * Validate the record data. + * + * @since 2.3.0 + * + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + public function validate() { + + // Check required fields + if (empty($this->type)) { + return new \WP_Error( + 'missing_type', + __('DNS record type is required.', 'ultimate-multisite') + ); + } + + if (empty($this->name)) { + return new \WP_Error( + 'missing_name', + __('DNS record name is required.', 'ultimate-multisite') + ); + } + + if (empty($this->content)) { + return new \WP_Error( + 'missing_content', + __('DNS record content is required.', 'ultimate-multisite') + ); + } + + // Validate type + if (! in_array($this->type, self::VALID_TYPES, true)) { + return new \WP_Error( + 'invalid_type', + sprintf( + /* translators: %s: list of valid types */ + __('Invalid DNS record type. Valid types are: %s', 'ultimate-multisite'), + implode(', ', self::VALID_TYPES) + ) + ); + } + + // Type-specific validation + $type_validation = $this->validate_by_type(); + if (is_wp_error($type_validation)) { + return $type_validation; + } + + // Validate TTL + if ($this->ttl < 1) { + return new \WP_Error( + 'invalid_ttl', + __('TTL must be a positive integer.', 'ultimate-multisite') + ); + } + + return true; + } + + /** + * Validate record based on its type. + * + * @since 2.3.0 + * + * @return true|\WP_Error + */ + protected function validate_by_type() { + + switch ($this->type) { + case 'A': + if (! filter_var($this->content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return new \WP_Error( + 'invalid_ipv4', + __('A record requires a valid IPv4 address.', 'ultimate-multisite') + ); + } + break; + + case 'AAAA': + if (! filter_var($this->content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return new \WP_Error( + 'invalid_ipv6', + __('AAAA record requires a valid IPv6 address.', 'ultimate-multisite') + ); + } + break; + + case 'CNAME': + // CNAME should be a hostname, not an IP + if (filter_var($this->content, FILTER_VALIDATE_IP)) { + return new \WP_Error( + 'invalid_cname', + __('CNAME record requires a hostname, not an IP address.', 'ultimate-multisite') + ); + } + break; + + case 'MX': + // MX requires priority + if (null === $this->priority || $this->priority < 0) { + return new \WP_Error( + 'missing_priority', + __('MX record requires a valid priority value.', 'ultimate-multisite') + ); + } + // MX should be a hostname + if (filter_var($this->content, FILTER_VALIDATE_IP)) { + return new \WP_Error( + 'invalid_mx', + __('MX record requires a mail server hostname, not an IP address.', 'ultimate-multisite') + ); + } + break; + + case 'TXT': + // TXT records can contain almost anything, but limit length + if (strlen($this->content) > 2048) { + return new \WP_Error( + 'txt_too_long', + __('TXT record content is too long (max 2048 characters).', 'ultimate-multisite') + ); + } + break; + } + + return true; + } + + /** + * Get CSS class for record type badge. + * + * @since 2.3.0 + * + * @return string + */ + public function get_type_class(): string { + + $classes = [ + 'A' => 'wu-bg-blue-100 wu-text-blue-800', + 'AAAA' => 'wu-bg-purple-100 wu-text-purple-800', + 'CNAME' => 'wu-bg-green-100 wu-text-green-800', + 'MX' => 'wu-bg-orange-100 wu-text-orange-800', + 'TXT' => 'wu-bg-gray-100 wu-text-gray-800', + ]; + + return $classes[ $this->type ] ?? 'wu-bg-gray-100 wu-text-gray-800'; + } + + /** + * Create a DNS_Record from provider-specific data. + * + * @since 2.3.0 + * + * @param array $data Provider data. + * @param string $provider Provider ID. + * @return self + */ + public static function from_provider(array $data, string $provider): self { + + // Normalize data based on provider format + switch ($provider) { + case 'cloudflare': + return new self( + [ + 'id' => $data['id'] ?? '', + 'type' => $data['type'] ?? 'A', + 'name' => $data['name'] ?? '', + 'content' => $data['content'] ?? '', + 'ttl' => $data['ttl'] ?? 1, + 'priority' => $data['priority'] ?? null, + 'proxied' => $data['proxied'] ?? false, + 'meta' => [ + 'zone_id' => $data['zone_id'] ?? '', + 'zone_name' => $data['zone_name'] ?? '', + ], + ] + ); + + case 'cpanel': + return new self( + [ + 'id' => $data['line_index'] ?? $data['line'] ?? '', + 'type' => $data['type'] ?? 'A', + 'name' => rtrim($data['name'] ?? '', '.'), + 'content' => $data['address'] ?? $data['cname'] ?? $data['exchange'] ?? $data['txtdata'] ?? '', + 'ttl' => (int) ($data['ttl'] ?? 14400), + 'priority' => isset($data['preference']) ? (int) $data['preference'] : null, + 'proxied' => false, + 'meta' => [ + 'line_index' => $data['line_index'] ?? '', + ], + ] + ); + + case 'hestia': + return new self( + [ + 'id' => $data['id'] ?? ($data['type'] . '-' . ($data['name'] ?? '@')), + 'type' => $data['type'] ?? 'A', + 'name' => $data['name'] ?? '@', + 'content' => $data['value'] ?? '', + 'ttl' => (int) ($data['ttl'] ?? 3600), + 'priority' => isset($data['priority']) ? (int) $data['priority'] : null, + 'proxied' => false, + 'meta' => [], + ] + ); + + default: + return new self($data); + } + } +} diff --git a/inc/integrations/host-providers/class-hestia-host-provider.php b/inc/integrations/host-providers/class-hestia-host-provider.php index d599fef1..a14c1feb 100644 --- a/inc/integrations/host-providers/class-hestia-host-provider.php +++ b/inc/integrations/host-providers/class-hestia-host-provider.php @@ -53,6 +53,7 @@ class Hestia_Host_Provider extends Base_Host_Provider { */ protected $supports = [ 'no-instructions', + 'dns-management', ]; /** @@ -334,4 +335,306 @@ protected function send_hestia_request($cmd, $args = []) { $json = json_decode($raw); return null !== $json ? $json : $raw; } + + /** + * Get DNS records for a domain. + * + * Uses Hestia v-list-dns-records command to retrieve DNS records. + * + * @since 2.3.0 + * + * @param string $domain The domain to query. + * @return array|\WP_Error Array of DNS_Record objects or WP_Error. + */ + public function get_dns_records(string $domain) { + + $account = defined('WU_HESTIA_ACCOUNT') ? WU_HESTIA_ACCOUNT : ''; + + if (empty($account)) { + return new \WP_Error('dns-error', __('Missing WU_HESTIA_ACCOUNT constant.', 'ultimate-multisite')); + } + + // Extract root domain for DNS zone + $zone = $this->extract_zone_name($domain); + + $result = $this->send_hestia_request('v-list-dns-records', [$account, $zone, 'json']); + + if (is_wp_error($result)) { + wu_log_add('integration-hestia', 'DNS fetch failed: ' . $result->get_error_message(), LogLevel::ERROR); + return $result; + } + + // Hestia returns 0 for success with no records, or a JSON object/array of records + if ('0' === $result || empty($result)) { + return []; + } + + $records = []; + $supported_types = $this->get_supported_record_types(); + + // Hestia returns records as an object with ID as keys + foreach ($result as $record_id => $record) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Hestia API property + $type = strtoupper($record->TYPE ?? ''); + + if (! in_array($type, $supported_types, true)) { + continue; + } + + $records[] = DNS_Record::from_provider( + [ + 'id' => $record_id, + 'type' => $type, + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Hestia API property + 'name' => $record->RECORD ?? '@', + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Hestia API property + 'content' => $record->VALUE ?? '', + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Hestia API property + 'ttl' => (int) ($record->TTL ?? 3600), + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Hestia API property + 'priority' => isset($record->PRIORITY) ? (int) $record->PRIORITY : null, + ], + 'hestia' + ); + } + + return $records; + } + + /** + * Create a DNS record for a domain. + * + * Uses Hestia v-add-dns-record command. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param array $record Record data (type, name, content, ttl, priority). + * @return array|\WP_Error Created record data or WP_Error. + */ + public function create_dns_record(string $domain, array $record) { + + $account = defined('WU_HESTIA_ACCOUNT') ? WU_HESTIA_ACCOUNT : ''; + + if (empty($account)) { + return new \WP_Error('dns-error', __('Missing WU_HESTIA_ACCOUNT constant.', 'ultimate-multisite')); + } + + $zone = $this->extract_zone_name($domain); + $type = strtoupper($record['type'] ?? 'A'); + $name = $record['name'] ?? '@'; + $value = $record['content'] ?? ''; + $priority = $record['priority'] ?? ''; + $ttl = (int) ($record['ttl'] ?? 3600); + + // Hestia v-add-dns-record USER DOMAIN RECORD TYPE VALUE [PRIORITY] [ID] [RESTART] [TTL] + $args = [$account, $zone, $name, $type, $value]; + + // Add priority for MX records + if ('MX' === $type && '' !== $priority) { + $args[] = (string) $priority; + } else { + $args[] = ''; // Empty priority + } + + $args[] = ''; // Auto-generate ID + $args[] = 'yes'; // Restart service + $args[] = (string) $ttl; + + $result = $this->send_hestia_request('v-add-dns-record', $args); + + if (is_wp_error($result)) { + wu_log_add('integration-hestia', 'DNS create failed: ' . $result->get_error_message(), LogLevel::ERROR); + return $result; + } + + // Hestia returns 0 on success + if ('0' !== $result && ! str_starts_with((string) $result, '0')) { + return new \WP_Error( + 'dns-create-error', + sprintf( + /* translators: %s: Hestia error code/message */ + __('Failed to create DNS record: %s', 'ultimate-multisite'), + is_scalar($result) ? $result : wp_json_encode($result) + ) + ); + } + + wu_log_add( + 'integration-hestia', + sprintf( + 'Created DNS record: %s %s -> %s', + $type, + $name, + $value + ) + ); + + return [ + 'id' => time(), // Hestia doesn't return the new ID + 'type' => $type, + 'name' => $name, + 'content' => $value, + 'ttl' => $ttl, + 'priority' => '' !== $priority ? (int) $priority : null, + ]; + } + + /** + * Update a DNS record for a domain. + * + * Uses Hestia v-change-dns-record command. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @param array $record Updated record data. + * @return array|\WP_Error Updated record data or WP_Error. + */ + public function update_dns_record(string $domain, string $record_id, array $record) { + + $account = defined('WU_HESTIA_ACCOUNT') ? WU_HESTIA_ACCOUNT : ''; + + if (empty($account)) { + return new \WP_Error('dns-error', __('Missing WU_HESTIA_ACCOUNT constant.', 'ultimate-multisite')); + } + + $zone = $this->extract_zone_name($domain); + $type = strtoupper($record['type'] ?? 'A'); + $name = $record['name'] ?? '@'; + $value = $record['content'] ?? ''; + $priority = $record['priority'] ?? ''; + $ttl = (int) ($record['ttl'] ?? 3600); + + // Hestia v-change-dns-record USER DOMAIN ID VALUE [PRIORITY] [RESTART] [TTL] + $args = [$account, $zone, $record_id, $value]; + + if ('MX' === $type && '' !== $priority) { + $args[] = (string) $priority; + } else { + $args[] = ''; + } + + $args[] = 'yes'; // Restart + $args[] = (string) $ttl; + + $result = $this->send_hestia_request('v-change-dns-record', $args); + + if (is_wp_error($result)) { + wu_log_add('integration-hestia', 'DNS update failed: ' . $result->get_error_message(), LogLevel::ERROR); + return $result; + } + + if ('0' !== $result && ! str_starts_with((string) $result, '0')) { + return new \WP_Error( + 'dns-update-error', + sprintf( + /* translators: %s: Hestia error code/message */ + __('Failed to update DNS record: %s', 'ultimate-multisite'), + is_scalar($result) ? $result : wp_json_encode($result) + ) + ); + } + + wu_log_add( + 'integration-hestia', + sprintf( + 'Updated DNS record ID %s: %s -> %s', + $record_id, + $name, + $value + ) + ); + + return [ + 'id' => $record_id, + 'type' => $type, + 'name' => $name, + 'content' => $value, + 'ttl' => $ttl, + 'priority' => '' !== $priority ? (int) $priority : null, + ]; + } + + /** + * Delete a DNS record for a domain. + * + * Uses Hestia v-delete-dns-record command. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param string $record_id The record identifier. + * @return bool|\WP_Error True on success or WP_Error. + */ + public function delete_dns_record(string $domain, string $record_id) { + + $account = defined('WU_HESTIA_ACCOUNT') ? WU_HESTIA_ACCOUNT : ''; + + if (empty($account)) { + return new \WP_Error('dns-error', __('Missing WU_HESTIA_ACCOUNT constant.', 'ultimate-multisite')); + } + + $zone = $this->extract_zone_name($domain); + + // Hestia v-delete-dns-record USER DOMAIN ID [RESTART] + $result = $this->send_hestia_request('v-delete-dns-record', [$account, $zone, $record_id, 'yes']); + + if (is_wp_error($result)) { + wu_log_add('integration-hestia', 'DNS delete failed: ' . $result->get_error_message(), LogLevel::ERROR); + return $result; + } + + if ('0' !== $result && ! str_starts_with((string) $result, '0')) { + return new \WP_Error( + 'dns-delete-error', + sprintf( + /* translators: %s: Hestia error code/message */ + __('Failed to delete DNS record: %s', 'ultimate-multisite'), + is_scalar($result) ? $result : wp_json_encode($result) + ) + ); + } + + wu_log_add( + 'integration-hestia', + sprintf( + 'Deleted DNS record ID %s from zone %s', + $record_id, + $zone + ) + ); + + return true; + } + + /** + * Extract the zone name (root domain) from a domain. + * + * @since 2.3.0 + * + * @param string $domain The domain name. + * @return string The zone name. + */ + protected function extract_zone_name(string $domain): string { + + $parts = explode('.', $domain); + + // Known multi-part TLDs + $multi_tlds = ['.co.uk', '.com.au', '.co.nz', '.com.br', '.co.in', '.org.uk', '.net.au']; + + foreach ($multi_tlds as $tld) { + if (str_ends_with($domain, $tld)) { + return implode('.', array_slice($parts, -3)); + } + } + + // Return last 2 parts for standard TLD + if (count($parts) >= 2) { + return implode('.', array_slice($parts, -2)); + } + + return $domain; + } } diff --git a/inc/integrations/host-providers/interface-dns-provider.php b/inc/integrations/host-providers/interface-dns-provider.php new file mode 100644 index 00000000..e84354e0 --- /dev/null +++ b/inc/integrations/host-providers/interface-dns-provider.php @@ -0,0 +1,115 @@ +get_integrations(); + + foreach ($integrations as $id => $class) { + $instance = $domain_manager->get_integration_instance($id); + + if ($instance && $instance->is_enabled() && $instance->supports_dns_management() && $instance->is_dns_enabled()) { + return $instance; + } + } + + return null; + } + + /** + * Get all DNS-capable providers. + * + * @since 2.3.0 + * + * @return array Array of provider instances that support DNS. + */ + public function get_dns_capable_providers(): array { + + $domain_manager = Domain_Manager::get_instance(); + $integrations = $domain_manager->get_integrations(); + $dns_providers = []; + + foreach ($integrations as $id => $class) { + $instance = $domain_manager->get_integration_instance($id); + + if ($instance && $instance->supports_dns_management()) { + $dns_providers[ $id ] = $instance; + } + } + + return $dns_providers; + } + + /** + * Check if customer can manage DNS for a domain. + * + * @since 2.3.0 + * + * @param int $user_id The user ID. + * @param string $domain The domain name. + * @return bool + */ + public function customer_can_manage_dns(int $user_id, string $domain): bool { + + // Super admins can always manage DNS + if (is_super_admin($user_id)) { + return true; + } + + // Check if customer DNS management is enabled + if (! wu_get_setting('enable_customer_dns_management', false)) { + return false; + } + + // Find the domain and check ownership + $domain_obj = wu_get_domain_by_domain($domain); + if (! $domain_obj) { + return false; + } + + $site = $domain_obj->get_site(); + if (! $site) { + return false; + } + + // Get customer for this user + $customer = wu_get_customer_by_user_id($user_id); + if (! $customer) { + return false; + } + + // Check if customer owns the site + return $site->get_customer_id() === $customer->get_id(); + } + + /** + * Get allowed record types for a user. + * + * @since 2.3.0 + * + * @param int $user_id The user ID. + * @return array + */ + public function get_allowed_record_types(int $user_id): array { + + // Super admins get all types + if (is_super_admin($user_id)) { + return DNS_Record::VALID_TYPES; + } + + // Get allowed types from settings + $allowed = wu_get_setting('dns_record_types_allowed', ['A', 'CNAME', 'TXT']); + + return array_intersect($allowed, DNS_Record::VALID_TYPES); + } + + /** + * AJAX handler for getting DNS records. + * + * @since 2.3.0 + * @return void + */ + public function ajax_get_records(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain = sanitize_text_field(wu_request('domain', '')); + $user_id = get_current_user_id(); + + if (empty($domain)) { + wp_send_json_error( + new \WP_Error( + 'missing_domain', + __('Domain is required.', 'ultimate-multisite') + ) + ); + } + + if (! $this->customer_can_manage_dns($user_id, $domain)) { + wp_send_json_error( + new \WP_Error( + 'permission_denied', + __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite') + ) + ); + } + + $provider = $this->get_dns_provider(); + + if (! $provider) { + // Fall back to read-only PHPDNS lookup + $records = Domain_Manager::dns_get_record($domain); + + wp_send_json_success( + [ + 'records' => $records, + 'readonly' => true, + 'message' => __('DNS management is not available. Records are read-only.', 'ultimate-multisite'), + ] + ); + } + + $records = $provider->get_dns_records($domain); + + if (is_wp_error($records)) { + // Fall back to PHPDNS on error + $fallback_records = Domain_Manager::dns_get_record($domain); + + wp_send_json_success( + [ + 'records' => $fallback_records, + 'readonly' => true, + 'message' => $records->get_error_message(), + ] + ); + } + + wp_send_json_success( + [ + 'records' => array_map(fn($r) => $r instanceof DNS_Record ? $r->to_array() : $r, $records), + 'readonly' => false, + 'provider' => $provider->get_id(), + 'record_types' => $provider->get_supported_record_types(), + ] + ); + } + + /** + * AJAX handler for creating DNS record. + * + * @since 2.3.0 + * @return void + */ + public function ajax_create_record(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain = sanitize_text_field(wu_request('domain', '')); + $record = wu_request('record', []); + $user_id = get_current_user_id(); + + if (empty($domain)) { + wp_send_json_error( + new \WP_Error( + 'missing_domain', + __('Domain is required.', 'ultimate-multisite') + ) + ); + } + + if (! $this->customer_can_manage_dns($user_id, $domain)) { + wp_send_json_error( + new \WP_Error( + 'permission_denied', + __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite') + ) + ); + } + + $provider = $this->get_dns_provider(); + + if (! $provider) { + wp_send_json_error( + new \WP_Error( + 'no_provider', + __('No DNS provider configured.', 'ultimate-multisite') + ) + ); + } + + // Sanitize record data + $record = $this->sanitize_record_data($record); + + // Check if record type is allowed for this user + $allowed_types = $this->get_allowed_record_types($user_id); + if (! in_array($record['type'], $allowed_types, true)) { + wp_send_json_error( + new \WP_Error( + 'type_not_allowed', + __('You are not allowed to create this type of DNS record.', 'ultimate-multisite') + ) + ); + } + + // Validate record + $dns_record = new DNS_Record($record); + $validation = $dns_record->validate(); + + if (is_wp_error($validation)) { + wp_send_json_error($validation); + } + + $result = $provider->create_dns_record($domain, $record); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + // Log the action + wu_log_add( + "dns-{$domain}", + sprintf( + /* translators: %1$s: record type, %2$s: record name, %3$s: record content */ + __('DNS record created: %1$s %2$s -> %3$s', 'ultimate-multisite'), + $record['type'], + $record['name'], + $record['content'] + ) + ); + + wp_send_json_success($result); + } + + /** + * AJAX handler for updating DNS record. + * + * @since 2.3.0 + * @return void + */ + public function ajax_update_record(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain = sanitize_text_field(wu_request('domain', '')); + $record_id = sanitize_text_field(wu_request('record_id', '')); + $record = wu_request('record', []); + $user_id = get_current_user_id(); + + if (empty($domain) || empty($record_id)) { + wp_send_json_error( + new \WP_Error( + 'missing_params', + __('Domain and record ID are required.', 'ultimate-multisite') + ) + ); + } + + if (! $this->customer_can_manage_dns($user_id, $domain)) { + wp_send_json_error( + new \WP_Error( + 'permission_denied', + __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite') + ) + ); + } + + $provider = $this->get_dns_provider(); + + if (! $provider) { + wp_send_json_error( + new \WP_Error( + 'no_provider', + __('No DNS provider configured.', 'ultimate-multisite') + ) + ); + } + + // Sanitize record data + $record = $this->sanitize_record_data($record); + + // Check if record type is allowed for this user + $allowed_types = $this->get_allowed_record_types($user_id); + if (! in_array($record['type'], $allowed_types, true)) { + wp_send_json_error( + new \WP_Error( + 'type_not_allowed', + __('You are not allowed to modify this type of DNS record.', 'ultimate-multisite') + ) + ); + } + + // Validate record + $dns_record = new DNS_Record($record); + $validation = $dns_record->validate(); + + if (is_wp_error($validation)) { + wp_send_json_error($validation); + } + + $result = $provider->update_dns_record($domain, $record_id, $record); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + // Log the action + wu_log_add( + "dns-{$domain}", + sprintf( + /* translators: %1$s: record ID, %2$s: record type, %3$s: record name */ + __('DNS record updated: ID %1$s (%2$s %3$s)', 'ultimate-multisite'), + $record_id, + $record['type'], + $record['name'] + ) + ); + + wp_send_json_success($result); + } + + /** + * AJAX handler for deleting DNS record. + * + * @since 2.3.0 + * @return void + */ + public function ajax_delete_record(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + $domain = sanitize_text_field(wu_request('domain', '')); + $record_id = sanitize_text_field(wu_request('record_id', '')); + $user_id = get_current_user_id(); + + if (empty($domain) || empty($record_id)) { + wp_send_json_error( + new \WP_Error( + 'missing_params', + __('Domain and record ID are required.', 'ultimate-multisite') + ) + ); + } + + if (! $this->customer_can_manage_dns($user_id, $domain)) { + wp_send_json_error( + new \WP_Error( + 'permission_denied', + __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite') + ) + ); + } + + $provider = $this->get_dns_provider(); + + if (! $provider) { + wp_send_json_error( + new \WP_Error( + 'no_provider', + __('No DNS provider configured.', 'ultimate-multisite') + ) + ); + } + + $result = $provider->delete_dns_record($domain, $record_id); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + // Log the action + wu_log_add( + "dns-{$domain}", + sprintf( + /* translators: %s: record ID */ + __('DNS record deleted: ID %s', 'ultimate-multisite'), + $record_id + ) + ); + + wp_send_json_success(['deleted' => true]); + } + + /** + * AJAX handler for bulk DNS operations (admin only). + * + * @since 2.3.0 + * @return void + */ + public function ajax_bulk_operations(): void { + + check_ajax_referer('wu_dns_nonce', 'nonce'); + + // Only super admins can perform bulk operations + if (! is_super_admin()) { + wp_send_json_error( + new \WP_Error( + 'permission_denied', + __('Only network administrators can perform bulk DNS operations.', 'ultimate-multisite') + ) + ); + } + + $operation = sanitize_text_field(wu_request('operation', '')); + $domain = sanitize_text_field(wu_request('domain', '')); + $records = wu_request('records', []); + + if (empty($domain) || empty($operation)) { + wp_send_json_error( + new \WP_Error( + 'missing_params', + __('Domain and operation are required.', 'ultimate-multisite') + ) + ); + } + + $provider = $this->get_dns_provider(); + + if (! $provider) { + wp_send_json_error( + new \WP_Error( + 'no_provider', + __('No DNS provider configured.', 'ultimate-multisite') + ) + ); + } + + $results = [ + 'success' => [], + 'failed' => [], + ]; + + switch ($operation) { + case 'delete': + foreach ($records as $record_id) { + $result = $provider->delete_dns_record($domain, sanitize_text_field($record_id)); + + if (is_wp_error($result)) { + $results['failed'][ $record_id ] = $result->get_error_message(); + } else { + $results['success'][] = $record_id; + } + } + break; + + case 'import': + foreach ($records as $record) { + $record = $this->sanitize_record_data($record); + $result = $provider->create_dns_record($domain, $record); + + if (is_wp_error($result)) { + $results['failed'][] = [ + 'record' => $record, + 'message' => $result->get_error_message(), + ]; + } else { + $results['success'][] = $result; + } + } + break; + + default: + wp_send_json_error( + new \WP_Error( + 'invalid_operation', + __('Invalid bulk operation.', 'ultimate-multisite') + ) + ); + } + + // Log the action + wu_log_add( + "dns-{$domain}", + sprintf( + /* translators: %1$s: operation, %2$d: success count, %3$d: failed count */ + __('Bulk DNS operation "%1$s": %2$d succeeded, %3$d failed', 'ultimate-multisite'), + $operation, + count($results['success']), + count($results['failed']) + ) + ); + + wp_send_json_success($results); + } + + /** + * Sanitize DNS record data. + * + * @since 2.3.0 + * + * @param array $record Raw record data. + * @return array Sanitized record data. + */ + protected function sanitize_record_data(array $record): array { + + return [ + 'type' => strtoupper(sanitize_text_field($record['type'] ?? 'A')), + 'name' => sanitize_text_field($record['name'] ?? ''), + 'content' => sanitize_text_field($record['content'] ?? ''), + 'ttl' => absint($record['ttl'] ?? 3600), + 'priority' => isset($record['priority']) ? absint($record['priority']) : null, + 'proxied' => ! empty($record['proxied']), + ]; + } + + /** + * Add DNS-related settings to domain mapping section. + * + * @since 2.3.0 + * @return void + */ + public function add_dns_settings(): void { + + wu_register_settings_field( + 'domain-mapping', + 'dns_management_header', + [ + 'title' => __('DNS Record Management', 'ultimate-multisite'), + 'desc' => __('Configure DNS record management features.', 'ultimate-multisite'), + 'type' => 'header', + ] + ); + + wu_register_settings_field( + 'domain-mapping', + 'enable_customer_dns_management', + [ + 'title' => __('Enable Customer DNS Management', 'ultimate-multisite'), + 'desc' => __('Allow customers to manage DNS records for their domains.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + 'require' => [ + 'enable_domain_mapping' => 1, + ], + ] + ); + + wu_register_settings_field( + 'domain-mapping', + 'dns_record_types_allowed', + [ + 'title' => __('Allowed Record Types for Customers', 'ultimate-multisite'), + 'desc' => __('Select which DNS record types customers can manage.', 'ultimate-multisite'), + 'type' => 'multiselect', + 'options' => [ + 'A' => __('A (IPv4 Address)', 'ultimate-multisite'), + 'AAAA' => __('AAAA (IPv6 Address)', 'ultimate-multisite'), + 'CNAME' => __('CNAME (Alias)', 'ultimate-multisite'), + 'MX' => __('MX (Mail Exchange)', 'ultimate-multisite'), + 'TXT' => __('TXT (Text Record)', 'ultimate-multisite'), + ], + 'default' => ['A', 'CNAME', 'TXT'], + 'require' => [ + 'enable_domain_mapping' => 1, + 'enable_customer_dns_management' => 1, + ], + ] + ); + + wu_register_settings_field( + 'domain-mapping', + 'dns_management_instructions', + [ + 'title' => __('DNS Management Instructions', 'ultimate-multisite'), + 'desc' => __('Instructions shown to customers when managing DNS records. HTML is allowed.', 'ultimate-multisite'), + 'type' => 'textarea', + 'default' => __('Manage your domain\'s DNS records below. Changes may take up to 24 hours to propagate across the internet.', 'ultimate-multisite'), + 'html_attr' => ['rows' => 3], + 'require' => [ + 'enable_domain_mapping' => 1, + 'enable_customer_dns_management' => 1, + ], + 'allow_html' => true, + ] + ); + + // Add per-provider DNS enable settings for capable providers + $dns_providers = $this->get_dns_capable_providers(); + + if (! empty($dns_providers)) { + wu_register_settings_field( + 'domain-mapping', + 'dns_provider_settings_header', + [ + 'title' => __('DNS Provider Settings', 'ultimate-multisite'), + 'desc' => __('Enable DNS management for specific hosting providers.', 'ultimate-multisite'), + 'type' => 'header', + ] + ); + + foreach ($dns_providers as $id => $provider) { + $dns_enabled = get_network_option(null, 'wu_dns_integrations_enabled', []); + + wu_register_settings_field( + 'domain-mapping', + "dns_provider_{$id}", + [ + 'title' => sprintf( + /* translators: %s: provider name */ + __('Enable DNS for %s', 'ultimate-multisite'), + $provider->get_title() + ), + 'desc' => sprintf( + /* translators: %s: provider name */ + __('Enable DNS record management via %s API.', 'ultimate-multisite'), + $provider->get_title() + ), + 'type' => 'toggle', + 'default' => 0, + 'value' => ! empty($dns_enabled[ $id ]) ? 1 : 0, + 'require' => [ + 'enable_domain_mapping' => 1, + ], + ] + ); + } + } + } + + /** + * Export DNS records to BIND format. + * + * @since 2.3.0 + * + * @param string $domain The domain. + * @param array $records Array of DNS records. + * @return string BIND zone file format. + */ + public function export_to_bind(string $domain, array $records): string { + + $output = "; Zone file for {$domain}\n"; + $output .= '; Exported by Ultimate Multisite on ' . current_time('mysql') . "\n\n"; + $output .= "\$ORIGIN {$domain}.\n"; + $output .= "\$TTL 3600\n\n"; + + foreach ($records as $record) { + if ($record instanceof DNS_Record) { + $record = $record->to_array(); + } + + $name = $record['name'] === $domain ? '@' : str_replace(".{$domain}", '', $record['name']); + $ttl = $record['ttl'] ?? 3600; + $type = $record['type']; + + switch ($type) { + case 'MX': + $priority = $record['priority'] ?? 10; + $output .= "{$name}\t{$ttl}\tIN\t{$type}\t{$priority}\t{$record['content']}.\n"; + break; + + case 'TXT': + $content = '"' . addslashes($record['content']) . '"'; + $output .= "{$name}\t{$ttl}\tIN\t{$type}\t{$content}\n"; + break; + + case 'CNAME': + $output .= "{$name}\t{$ttl}\tIN\t{$type}\t{$record['content']}.\n"; + break; + + default: + $output .= "{$name}\t{$ttl}\tIN\t{$type}\t{$record['content']}\n"; + } + } + + return $output; + } + + /** + * Parse BIND format to DNS records. + * + * @since 2.3.0 + * + * @param string $content BIND zone file content. + * @param string $domain The domain name. + * @return array Array of parsed records. + */ + public function parse_bind_format(string $content, string $domain): array { + + $records = []; + $lines = explode("\n", $content); + $default_ttl = 3600; + + foreach ($lines as $line) { + $line = trim($line); + + // Skip comments and empty lines + if (empty($line) || strpos($line, ';') === 0) { + continue; + } + + // Parse $TTL directive + if (preg_match('/^\$TTL\s+(\d+)/i', $line, $matches)) { + $default_ttl = (int) $matches[1]; + continue; + } + + // Skip other directives + if (strpos($line, '$') === 0) { + continue; + } + + // Parse record line + // Format: name [ttl] [class] type content + $parts = preg_split('/\s+/', $line); + + if (count($parts) < 3) { + continue; + } + + $record = [ + 'name' => '', + 'ttl' => $default_ttl, + 'type' => '', + 'content' => '', + ]; + + $idx = 0; + + // Name + $record['name'] = $parts[ $idx ]; + if ('@' === $record['name']) { + $record['name'] = $domain; + } + ++$idx; + + // TTL (optional) + if (isset($parts[ $idx ]) && is_numeric($parts[ $idx ])) { + $record['ttl'] = (int) $parts[ $idx ]; + ++$idx; + } + + // Class (optional, usually IN) + if (isset($parts[ $idx ]) && 'IN' === strtoupper($parts[ $idx ])) { + ++$idx; + } + + // Type + if (isset($parts[ $idx ])) { + $record['type'] = strtoupper($parts[ $idx ]); + ++$idx; + } + + // Content (rest of the line) + if ('MX' === $record['type'] && isset($parts[ $idx ])) { + $record['priority'] = (int) $parts[ $idx ]; + ++$idx; + } + + $content_parts = array_slice($parts, $idx); + $content = implode(' ', $content_parts); + + // Clean up content + $content = rtrim($content, '.'); + $content = trim($content, '"'); + + $record['content'] = $content; + + // Only include supported record types + if (in_array($record['type'], DNS_Record::VALID_TYPES, true)) { + $records[] = $record; + } + } + + return $records; + } +} diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 4273338b..0625a540 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -139,6 +139,9 @@ public function init(): void { add_action('plugins_loaded', [$this, 'load_integrations']); + // Initialize DNS Record Manager + add_action('plugins_loaded', [$this, 'init_dns_record_manager'], 11); + add_action('wp_ajax_wu_test_hosting_integration', [$this, 'test_integration']); add_action('wp_ajax_wu_get_dns_records', [$this, 'get_dns_records']); @@ -184,6 +187,18 @@ protected function set_cookie_domain() { } } + /** + * Initialize the DNS Record Manager. + * + * @since 2.3.0 + * + * @return void + */ + public function init_dns_record_manager(): void { + + DNS_Record_Manager::get_instance(); + } + /** * Triggers subdomain mapping events on site creation. * diff --git a/inc/ui/class-domain-mapping-element.php b/inc/ui/class-domain-mapping-element.php index 2c4834dd..274665f3 100644 --- a/inc/ui/class-domain-mapping-element.php +++ b/inc/ui/class-domain-mapping-element.php @@ -275,6 +275,45 @@ public function register_forms(): void { 'capability' => 'exist', ] ); + + /* + * DNS Management Forms + */ + wu_register_form( + 'user_manage_dns_records', + [ + 'render' => [$this, 'render_dns_management_modal'], + 'handler' => false, + 'capability' => 'exist', + ] + ); + + wu_register_form( + 'user_add_dns_record', + [ + 'render' => [$this, 'render_add_dns_record_modal'], + 'handler' => [$this, 'handle_add_dns_record'], + 'capability' => 'exist', + ] + ); + + wu_register_form( + 'user_edit_dns_record', + [ + 'render' => [$this, 'render_edit_dns_record_modal'], + 'handler' => [$this, 'handle_edit_dns_record'], + 'capability' => 'exist', + ] + ); + + wu_register_form( + 'user_delete_dns_record', + [ + 'render' => [$this, 'render_delete_dns_record_modal'], + 'handler' => [$this, 'handle_delete_dns_record'], + 'capability' => 'exist', + ] + ); } /** @@ -644,6 +683,322 @@ public function handle_user_make_domain_primary_modal(): void { wp_send_json_error(new \WP_Error('error', __('Something wrong happenned.', 'ultimate-multisite'))); } + /** + * Renders the DNS management modal. + * + * @since 2.3.0 + * @return void + */ + public function render_dns_management_modal(): void { + + $domain_id = wu_request('domain_id'); + $domain = wu_get_domain($domain_id); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $provider = $dns_manager->get_dns_provider(); + + wu_get_template( + 'domain/dns-management-modal', + [ + 'domain' => $domain, + 'domain_id' => $domain_id, + 'site_id' => $domain->get_blog_id(), + 'can_manage' => $dns_manager->customer_can_manage_dns(get_current_user_id(), $domain->get_domain()), + 'has_provider' => null !== $provider, + 'provider_name' => $provider ? $provider->get_title() : '', + ] + ); + } + + /** + * Renders the add DNS record modal. + * + * @since 2.3.0 + * @return void + */ + public function render_add_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $domain = wu_get_domain($domain_id); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $provider = $dns_manager->get_dns_provider(); + + wu_get_template( + 'domain/dns-record-form', + [ + 'domain_id' => $domain_id, + 'domain_name' => $domain->get_domain(), + 'mode' => 'add', + 'record' => [], + 'allowed_types' => $dns_manager->get_allowed_record_types(get_current_user_id()), + 'show_proxied' => $provider && $provider->get_id() === 'cloudflare', + ] + ); + } + + /** + * Handles adding a DNS record. + * + * @since 2.3.0 + * @return void + */ + public function handle_add_dns_record(): void { + + $domain_id = wu_request('domain_id'); + $domain = wu_get_domain($domain_id); + $record = wu_request('record', []); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + + if (! $dns_manager->customer_can_manage_dns(get_current_user_id(), $domain->get_domain())) { + wp_send_json_error(new \WP_Error('permission-denied', __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite'))); + return; + } + + $provider = $dns_manager->get_dns_provider(); + + if (! $provider) { + wp_send_json_error(new \WP_Error('no-provider', __('No DNS provider configured.', 'ultimate-multisite'))); + return; + } + + $result = $provider->create_dns_record($domain->get_domain(), $record); + + if (is_wp_error($result)) { + wp_send_json_error($result); + return; + } + + wp_send_json_success( + [ + 'redirect_url' => wu_get_form_url('user_manage_dns_records', ['domain_id' => $domain_id]), + ] + ); + } + + /** + * Renders the edit DNS record modal. + * + * @since 2.3.0 + * @return void + */ + public function render_edit_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $provider = $dns_manager->get_dns_provider(); + + if (! $provider) { + wp_send_json_error(new \WP_Error('no-provider', __('No DNS provider configured.', 'ultimate-multisite'))); + return; + } + + // Get current record data + $records = $provider->get_dns_records($domain->get_domain()); + $record = []; + + if (! is_wp_error($records)) { + foreach ($records as $r) { + $record_data = $r instanceof \WP_Ultimo\Integrations\Host_Providers\DNS_Record ? $r->to_array() : $r; + if (($record_data['id'] ?? '') === $record_id) { + $record = $record_data; + break; + } + } + } + + wu_get_template( + 'domain/dns-record-form', + [ + 'domain_id' => $domain_id, + 'domain_name' => $domain->get_domain(), + 'mode' => 'edit', + 'record' => $record, + 'allowed_types' => $dns_manager->get_allowed_record_types(get_current_user_id()), + 'show_proxied' => $provider->get_id() === 'cloudflare', + ] + ); + } + + /** + * Handles editing a DNS record. + * + * @since 2.3.0 + * @return void + */ + public function handle_edit_dns_record(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + $record = wu_request('record', []); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + + if (! $dns_manager->customer_can_manage_dns(get_current_user_id(), $domain->get_domain())) { + wp_send_json_error(new \WP_Error('permission-denied', __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite'))); + return; + } + + $provider = $dns_manager->get_dns_provider(); + + if (! $provider) { + wp_send_json_error(new \WP_Error('no-provider', __('No DNS provider configured.', 'ultimate-multisite'))); + return; + } + + $result = $provider->update_dns_record($domain->get_domain(), $record_id, $record); + + if (is_wp_error($result)) { + wp_send_json_error($result); + return; + } + + wp_send_json_success( + [ + 'redirect_url' => wu_get_form_url('user_manage_dns_records', ['domain_id' => $domain_id]), + ] + ); + } + + /** + * Renders the delete DNS record confirmation modal. + * + * @since 2.3.0 + * @return void + */ + public function render_delete_dns_record_modal(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $fields = [ + 'confirm' => [ + 'type' => 'toggle', + 'title' => __('Confirm Deletion', 'ultimate-multisite'), + 'desc' => __('I understand this action cannot be undone.', 'ultimate-multisite'), + 'html_attr' => [ + 'v-model' => 'confirmed', + ], + ], + 'domain_id' => [ + 'type' => 'hidden', + 'value' => $domain_id, + ], + 'record_id' => [ + 'type' => 'hidden', + 'value' => $record_id, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Delete Record', 'ultimate-multisite'), + 'placeholder' => __('Delete Record', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end', + 'html_attr' => [ + 'v-bind:disabled' => '!confirmed', + ], + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'delete_dns_record', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'delete_dns_record', + 'data-state' => wp_json_encode(['confirmed' => false]), + ], + ] + ); + + $form->render(); + } + + /** + * Handles deleting a DNS record. + * + * @since 2.3.0 + * @return void + */ + public function handle_delete_dns_record(): void { + + $domain_id = wu_request('domain_id'); + $record_id = wu_request('record_id'); + $domain = wu_get_domain($domain_id); + + if (! $domain) { + wp_send_json_error(new \WP_Error('invalid-domain', __('Invalid domain.', 'ultimate-multisite'))); + return; + } + + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + + if (! $dns_manager->customer_can_manage_dns(get_current_user_id(), $domain->get_domain())) { + wp_send_json_error(new \WP_Error('permission-denied', __('You do not have permission to manage DNS for this domain.', 'ultimate-multisite'))); + return; + } + + $provider = $dns_manager->get_dns_provider(); + + if (! $provider) { + wp_send_json_error(new \WP_Error('no-provider', __('No DNS provider configured.', 'ultimate-multisite'))); + return; + } + + $result = $provider->delete_dns_record($domain->get_domain(), $record_id); + + if (is_wp_error($result)) { + wp_send_json_error($result); + return; + } + + wp_send_json_success( + [ + 'redirect_url' => wu_get_form_url('user_manage_dns_records', ['domain_id' => $domain_id]), + ] + ); + } + /** * Runs early on the request lifecycle as soon as we detect the shortcode is present. * diff --git a/tests/WP_Ultimo/Integrations/Host_Providers/CPanel_DNS_Test.php b/tests/WP_Ultimo/Integrations/Host_Providers/CPanel_DNS_Test.php new file mode 100644 index 00000000..51c673f8 --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Host_Providers/CPanel_DNS_Test.php @@ -0,0 +1,208 @@ +provider = CPanel_Host_Provider::get_instance(); + } + + /** + * Test that cPanel implements DNS_Provider_Interface. + */ + public function test_implements_dns_interface() { + $this->assertInstanceOf(DNS_Provider_Interface::class, $this->provider); + } + + /** + * Test supports_dns_management returns true. + */ + public function test_supports_dns_management() { + $this->assertTrue($this->provider->supports_dns_management()); + } + + /** + * Test get_supported_record_types returns expected types. + */ + public function test_get_supported_record_types() { + $types = $this->provider->get_supported_record_types(); + + $this->assertIsArray($types); + $this->assertContains('A', $types); + $this->assertContains('AAAA', $types); + $this->assertContains('CNAME', $types); + $this->assertContains('MX', $types); + $this->assertContains('TXT', $types); + } + + /** + * Test extract_zone_name helper method. + */ + public function test_extract_zone_name() { + $method = new \ReflectionMethod($this->provider, 'extract_zone_name'); + $method->setAccessible(true); + + // Standard TLD + $this->assertEquals('example.com', $method->invoke($this->provider, 'www.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'sub.test.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'example.com')); + + // Multi-part TLDs + $this->assertEquals('example.co.uk', $method->invoke($this->provider, 'www.example.co.uk')); + $this->assertEquals('example.com.au', $method->invoke($this->provider, 'sub.example.com.au')); + } + + /** + * Test format_record_name helper method. + */ + public function test_format_record_name() { + $method = new \ReflectionMethod($this->provider, 'format_record_name'); + $method->setAccessible(true); + + // Root domain (@) + $this->assertEquals('example.com.', $method->invoke($this->provider, '@', 'example.com')); + + // Subdomain + $this->assertEquals('www.example.com.', $method->invoke($this->provider, 'www', 'example.com')); + + // Full domain name + $this->assertEquals('test.example.com.', $method->invoke($this->provider, 'test.example.com', 'example.com')); + } + + /** + * Test ensure_trailing_dot helper method. + */ + public function test_ensure_trailing_dot() { + $method = new \ReflectionMethod($this->provider, 'ensure_trailing_dot'); + $method->setAccessible(true); + + $this->assertEquals('example.com.', $method->invoke($this->provider, 'example.com')); + $this->assertEquals('example.com.', $method->invoke($this->provider, 'example.com.')); + $this->assertEquals('mail.example.com.', $method->invoke($this->provider, 'mail.example.com')); + } + + /** + * Test get_dns_records returns WP_Error when not configured. + */ + public function test_get_dns_records_not_configured() { + $result = $this->provider->get_dns_records('example.com'); + + // Without proper API credentials, should return WP_Error + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test create_dns_record returns WP_Error when not configured. + */ + public function test_create_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.1', + 'ttl' => 14400, + ]; + + $result = $this->provider->create_dns_record('example.com', $record); + + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test update_dns_record returns WP_Error when not configured. + */ + public function test_update_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.2', + 'ttl' => 14400, + ]; + + $result = $this->provider->update_dns_record('example.com', '42', $record); + + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test delete_dns_record returns WP_Error when not configured. + */ + public function test_delete_dns_record_not_configured() { + $result = $this->provider->delete_dns_record('example.com', '42'); + + $this->assertTrue(is_wp_error($result) || $result === true); + } + + /** + * Test is_dns_enabled default value. + */ + public function test_is_dns_enabled_default() { + $result = $this->provider->is_dns_enabled(); + $this->assertIsBool($result); + } + + /** + * Test enable_dns and disable_dns toggle. + */ + public function test_enable_disable_dns() { + $this->provider->enable_dns(); + $this->assertTrue($this->provider->is_dns_enabled()); + + $this->provider->disable_dns(); + $this->assertFalse($this->provider->is_dns_enabled()); + } + + /** + * Test provider ID is correct. + */ + public function test_provider_id() { + $this->assertEquals('cpanel', $this->provider->get_id()); + } + + /** + * Test provider title. + */ + public function test_provider_title() { + $title = $this->provider->get_title(); + $this->assertStringContainsString('cPanel', $title); + } + + /** + * Test default TTL for cPanel (14400). + */ + public function test_default_ttl() { + // cPanel typically uses 14400 as default TTL + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.1', + ]; + + // This tests that the record data is properly structured + $this->assertEquals('A', $record['type']); + } +} diff --git a/tests/WP_Ultimo/Integrations/Host_Providers/Cloudflare_DNS_Test.php b/tests/WP_Ultimo/Integrations/Host_Providers/Cloudflare_DNS_Test.php new file mode 100644 index 00000000..db8950bc --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Host_Providers/Cloudflare_DNS_Test.php @@ -0,0 +1,171 @@ +provider = Cloudflare_Host_Provider::get_instance(); + } + + /** + * Test that Cloudflare implements DNS_Provider_Interface. + */ + public function test_implements_dns_interface() { + $this->assertInstanceOf(DNS_Provider_Interface::class, $this->provider); + } + + /** + * Test supports_dns_management returns true. + */ + public function test_supports_dns_management() { + $this->assertTrue($this->provider->supports_dns_management()); + } + + /** + * Test get_supported_record_types returns expected types. + */ + public function test_get_supported_record_types() { + $types = $this->provider->get_supported_record_types(); + + $this->assertIsArray($types); + $this->assertContains('A', $types); + $this->assertContains('AAAA', $types); + $this->assertContains('CNAME', $types); + $this->assertContains('MX', $types); + $this->assertContains('TXT', $types); + } + + /** + * Test extract_root_domain helper method. + */ + public function test_extract_root_domain() { + $method = new \ReflectionMethod($this->provider, 'extract_root_domain'); + $method->setAccessible(true); + + // Standard TLD + $this->assertEquals('example.com', $method->invoke($this->provider, 'www.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'sub.test.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'example.com')); + + // Multi-part TLDs + $this->assertEquals('example.co.uk', $method->invoke($this->provider, 'www.example.co.uk')); + $this->assertEquals('example.com.au', $method->invoke($this->provider, 'sub.example.com.au')); + } + + /** + * Test get_dns_records returns WP_Error when not configured. + */ + public function test_get_dns_records_not_configured() { + // Without proper API credentials, should return WP_Error + $result = $this->provider->get_dns_records('example.com'); + + // This will fail as we don't have real credentials + // In a real test environment, you'd mock the API + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test create_dns_record returns WP_Error when not configured. + */ + public function test_create_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.1', + 'ttl' => 3600, + ]; + + $result = $this->provider->create_dns_record('example.com', $record); + + // Without credentials, should return error + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test update_dns_record returns WP_Error when not configured. + */ + public function test_update_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.2', + 'ttl' => 7200, + ]; + + $result = $this->provider->update_dns_record('example.com', 'record_123', $record); + + $this->assertTrue(is_wp_error($result) || is_array($result)); + } + + /** + * Test delete_dns_record returns WP_Error when not configured. + */ + public function test_delete_dns_record_not_configured() { + $result = $this->provider->delete_dns_record('example.com', 'record_123'); + + $this->assertTrue(is_wp_error($result) || $result === true); + } + + /** + * Test is_dns_enabled default value. + */ + public function test_is_dns_enabled_default() { + // By default, DNS should not be enabled until explicitly set + $result = $this->provider->is_dns_enabled(); + + $this->assertIsBool($result); + } + + /** + * Test enable_dns and disable_dns toggle. + */ + public function test_enable_disable_dns() { + // Enable DNS + $this->provider->enable_dns(); + $this->assertTrue($this->provider->is_dns_enabled()); + + // Disable DNS + $this->provider->disable_dns(); + $this->assertFalse($this->provider->is_dns_enabled()); + } + + /** + * Test provider ID is correct. + */ + public function test_provider_id() { + $this->assertEquals('cloudflare', $this->provider->get_id()); + } + + /** + * Test provider title. + */ + public function test_provider_title() { + $title = $this->provider->get_title(); + $this->assertStringContainsString('Cloudflare', $title); + } +} diff --git a/tests/WP_Ultimo/Integrations/Host_Providers/DNS_Record_Test.php b/tests/WP_Ultimo/Integrations/Host_Providers/DNS_Record_Test.php new file mode 100644 index 00000000..a0801c9e --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Host_Providers/DNS_Record_Test.php @@ -0,0 +1,487 @@ + '123', + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + 'ttl' => 3600, + ]); + + $this->assertEquals('123', $record->id); + $this->assertEquals('A', $record->get_type()); + $this->assertEquals('example.com', $record->get_name()); + $this->assertEquals('192.168.1.1', $record->get_content()); + $this->assertEquals(3600, $record->get_ttl()); + $this->assertNull($record->get_priority()); + } + + /** + * Test creating an AAAA record. + */ + public function test_create_aaaa_record() { + $record = new DNS_Record([ + 'id' => '124', + 'type' => 'AAAA', + 'name' => 'ipv6.example.com', + 'content' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + 'ttl' => 7200, + ]); + + $this->assertEquals('AAAA', $record->get_type()); + $this->assertEquals('2001:0db8:85a3:0000:0000:8a2e:0370:7334', $record->get_content()); + } + + /** + * Test creating a CNAME record. + */ + public function test_create_cname_record() { + $record = new DNS_Record([ + 'id' => '125', + 'type' => 'CNAME', + 'name' => 'www.example.com', + 'content' => 'example.com', + 'ttl' => 3600, + ]); + + $this->assertEquals('CNAME', $record->get_type()); + $this->assertEquals('www.example.com', $record->get_name()); + $this->assertEquals('example.com', $record->get_content()); + } + + /** + * Test creating an MX record with priority. + */ + public function test_create_mx_record() { + $record = new DNS_Record([ + 'id' => '126', + 'type' => 'MX', + 'name' => 'example.com', + 'content' => 'mail.example.com', + 'ttl' => 3600, + 'priority' => 10, + ]); + + $this->assertEquals('MX', $record->get_type()); + $this->assertEquals('mail.example.com', $record->get_content()); + $this->assertEquals(10, $record->get_priority()); + } + + /** + * Test creating a TXT record. + */ + public function test_create_txt_record() { + $record = new DNS_Record([ + 'id' => '127', + 'type' => 'TXT', + 'name' => 'example.com', + 'content' => 'v=spf1 include:_spf.google.com ~all', + 'ttl' => 3600, + ]); + + $this->assertEquals('TXT', $record->get_type()); + $this->assertEquals('v=spf1 include:_spf.google.com ~all', $record->get_content()); + } + + /** + * Test to_array method. + */ + public function test_to_array() { + $data = [ + 'id' => '128', + 'type' => 'MX', + 'name' => 'example.com', + 'content' => 'mail.example.com', + 'ttl' => 3600, + 'priority' => 5, + 'proxied' => false, + ]; + + $record = new DNS_Record($data); + $array = $record->to_array(); + + $this->assertEquals('128', $array['id']); + $this->assertEquals('MX', $array['type']); + $this->assertEquals('example.com', $array['name']); + $this->assertEquals('mail.example.com', $array['content']); + $this->assertEquals(3600, $array['ttl']); + $this->assertEquals(5, $array['priority']); + $this->assertFalse($array['proxied']); + } + + /** + * Test VALID_TYPES constant contains expected types. + */ + public function test_valid_types_constant() { + $this->assertContains('A', DNS_Record::VALID_TYPES); + $this->assertContains('AAAA', DNS_Record::VALID_TYPES); + $this->assertContains('CNAME', DNS_Record::VALID_TYPES); + $this->assertContains('MX', DNS_Record::VALID_TYPES); + $this->assertContains('TXT', DNS_Record::VALID_TYPES); + } + + /** + * Test validate method with valid A record. + */ + public function test_validate_valid_a_record() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + ]); + + $this->assertTrue($record->validate()); + } + + /** + * Test validate method with invalid type. + */ + public function test_validate_invalid_type() { + $record = new DNS_Record([ + 'type' => 'INVALID', + 'name' => 'example.com', + 'content' => 'value', + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_type', $result->get_error_code()); + } + + /** + * Test validate method with empty name. + */ + public function test_validate_empty_name() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => '', + 'content' => '192.168.1.1', + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('missing_name', $result->get_error_code()); + } + + /** + * Test validate method with empty content. + */ + public function test_validate_empty_content() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '', + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('missing_content', $result->get_error_code()); + } + + /** + * Test validate_by_type with invalid IPv4 for A record. + */ + public function test_validate_invalid_ipv4() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => 'not-an-ip', + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_ipv4', $result->get_error_code()); + } + + /** + * Test validate_by_type with invalid IPv6 for AAAA record. + */ + public function test_validate_invalid_ipv6() { + $record = new DNS_Record([ + 'type' => 'AAAA', + 'name' => 'example.com', + 'content' => '192.168.1.1', // IPv4 is invalid for AAAA + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_ipv6', $result->get_error_code()); + } + + /** + * Test validate_by_type with IP address for CNAME (should fail). + */ + public function test_validate_invalid_cname_with_ip() { + $record = new DNS_Record([ + 'type' => 'CNAME', + 'name' => 'www.example.com', + 'content' => '192.168.1.1', // IP not allowed for CNAME + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_cname', $result->get_error_code()); + } + + /** + * Test from_provider method with Cloudflare data. + */ + public function test_from_provider_cloudflare() { + $cloudflare_data = [ + 'id' => 'cf_record_123', + 'type' => 'A', + 'name' => 'test.example.com', + 'content' => '192.168.1.100', + 'ttl' => 1, + 'proxied' => true, + ]; + + $record = DNS_Record::from_provider($cloudflare_data, 'cloudflare'); + + $this->assertEquals('cf_record_123', $record->id); + $this->assertEquals('A', $record->get_type()); + $this->assertEquals('test.example.com', $record->get_name()); + $this->assertEquals('192.168.1.100', $record->get_content()); + $this->assertEquals(1, $record->get_ttl()); + $this->assertTrue($record->is_proxied()); + } + + /** + * Test from_provider method with cPanel data. + */ + public function test_from_provider_cpanel() { + $cpanel_data = [ + 'line_index' => '42', + 'type' => 'MX', + 'name' => 'example.com.', + 'exchange' => 'mail.example.com.', + 'ttl' => 14400, + 'preference' => 10, + ]; + + $record = DNS_Record::from_provider($cpanel_data, 'cpanel'); + + $this->assertEquals('42', $record->id); + $this->assertEquals('MX', $record->get_type()); + $this->assertEquals('example.com', $record->get_name()); // Trailing dot removed + $this->assertEquals('mail.example.com.', $record->get_content()); + $this->assertEquals(10, $record->get_priority()); + } + + /** + * Test from_provider method with Hestia data. + */ + public function test_from_provider_hestia() { + $hestia_data = [ + 'id' => 'record_1', + 'type' => 'TXT', + 'name' => '@', + 'value' => 'v=spf1 mx ~all', + 'ttl' => 3600, + ]; + + $record = DNS_Record::from_provider($hestia_data, 'hestia'); + + $this->assertEquals('record_1', $record->id); + $this->assertEquals('TXT', $record->get_type()); + $this->assertEquals('@', $record->get_name()); + $this->assertEquals('v=spf1 mx ~all', $record->get_content()); + } + + /** + * Test get_type_class returns correct CSS class for each type. + */ + public function test_get_type_class() { + $a_record = new DNS_Record(['type' => 'A', 'name' => 'test', 'content' => '1.1.1.1']); + $aaaa_record = new DNS_Record(['type' => 'AAAA', 'name' => 'test', 'content' => '::1']); + $cname_record = new DNS_Record(['type' => 'CNAME', 'name' => 'test', 'content' => 'target.com']); + $mx_record = new DNS_Record(['type' => 'MX', 'name' => 'test', 'content' => 'mail.test.com', 'priority' => 10]); + $txt_record = new DNS_Record(['type' => 'TXT', 'name' => 'test', 'content' => 'test']); + + $this->assertStringContainsString('blue', $a_record->get_type_class()); + $this->assertStringContainsString('purple', $aaaa_record->get_type_class()); + $this->assertStringContainsString('green', $cname_record->get_type_class()); + $this->assertStringContainsString('orange', $mx_record->get_type_class()); + $this->assertStringContainsString('gray', $txt_record->get_type_class()); + } + + /** + * Test get_ttl_label returns correct human-readable format. + */ + public function test_get_ttl_label() { + $auto_record = new DNS_Record(['type' => 'A', 'name' => 'test', 'content' => '1.1.1.1', 'ttl' => 1]); + $this->assertEquals('Auto', $auto_record->get_ttl_label()); + + $hour_record = new DNS_Record(['type' => 'A', 'name' => 'test', 'content' => '1.1.1.1', 'ttl' => 3600]); + $this->assertEquals('1 hour', $hour_record->get_ttl_label()); + } + + /** + * Test default TTL value. + */ + public function test_default_ttl() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + ]); + + $this->assertEquals(3600, $record->get_ttl()); + } + + /** + * Test is_proxied method. + */ + public function test_is_proxied() { + $proxied_record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + 'proxied' => true, + ]); + + $unproxied_record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + 'proxied' => false, + ]); + + $this->assertTrue($proxied_record->is_proxied()); + $this->assertFalse($unproxied_record->is_proxied()); + } + + /** + * Test meta data storage and retrieval. + */ + public function test_meta_data() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'example.com', + 'content' => '192.168.1.1', + 'meta' => [ + 'custom_key' => 'custom_value', + 'zone_id' => 'zone123', + ], + ]); + + $meta = $record->get_meta(); + $this->assertIsArray($meta); + $this->assertEquals('custom_value', $meta['custom_key']); + $this->assertEquals('zone123', $meta['zone_id']); + + // Test specific key retrieval + $this->assertEquals('custom_value', $record->get_meta('custom_key')); + $this->assertNull($record->get_meta('nonexistent')); + } + + /** + * Test type normalization to uppercase. + */ + public function test_type_normalization() { + $record = new DNS_Record([ + 'type' => 'cname', + 'name' => 'www.example.com', + 'content' => 'example.com', + ]); + + $this->assertEquals('CNAME', $record->get_type()); + } + + /** + * Test MX record without priority fails validation. + */ + public function test_mx_validation_requires_priority() { + $record = new DNS_Record([ + 'type' => 'MX', + 'name' => 'example.com', + 'content' => 'mail.example.com', + ]); + + $result = $record->validate(); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('missing_priority', $result->get_error_code()); + } + + /** + * Test valid CNAME hostname validation. + */ + public function test_valid_cname_hostname() { + $record = new DNS_Record([ + 'type' => 'CNAME', + 'name' => 'www.example.com', + 'content' => 'target.example.com', + ]); + + $this->assertTrue($record->validate()); + } + + /** + * Test valid MX record with priority. + */ + public function test_valid_mx_with_priority() { + $record = new DNS_Record([ + 'type' => 'MX', + 'name' => 'example.com', + 'content' => 'mail.example.com', + 'priority' => 10, + ]); + + $this->assertTrue($record->validate()); + } + + /** + * Test get_full_name with root domain. + */ + public function test_get_full_name_root() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => '@', + 'content' => '192.168.1.1', + ]); + + $this->assertEquals('example.com', $record->get_full_name('example.com')); + } + + /** + * Test get_full_name with subdomain. + */ + public function test_get_full_name_subdomain() { + $record = new DNS_Record([ + 'type' => 'A', + 'name' => 'www', + 'content' => '192.168.1.1', + ]); + + $this->assertEquals('www.example.com', $record->get_full_name('example.com')); + } + + /** + * Test TTL_OPTIONS constant. + */ + public function test_ttl_options_constant() { + $this->assertIsArray(DNS_Record::TTL_OPTIONS); + $this->assertArrayHasKey(3600, DNS_Record::TTL_OPTIONS); + $this->assertEquals('1 hour', DNS_Record::TTL_OPTIONS[3600]); + } +} diff --git a/tests/WP_Ultimo/Integrations/Host_Providers/Hestia_DNS_Test.php b/tests/WP_Ultimo/Integrations/Host_Providers/Hestia_DNS_Test.php new file mode 100644 index 00000000..7317cc12 --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Host_Providers/Hestia_DNS_Test.php @@ -0,0 +1,197 @@ +provider = Hestia_Host_Provider::get_instance(); + } + + /** + * Test that Hestia implements DNS_Provider_Interface. + */ + public function test_implements_dns_interface() { + $this->assertInstanceOf(DNS_Provider_Interface::class, $this->provider); + } + + /** + * Test supports_dns_management returns true. + */ + public function test_supports_dns_management() { + $this->assertTrue($this->provider->supports_dns_management()); + } + + /** + * Test get_supported_record_types returns expected types. + */ + public function test_get_supported_record_types() { + $types = $this->provider->get_supported_record_types(); + + $this->assertIsArray($types); + $this->assertContains('A', $types); + $this->assertContains('AAAA', $types); + $this->assertContains('CNAME', $types); + $this->assertContains('MX', $types); + $this->assertContains('TXT', $types); + } + + /** + * Test extract_zone_name helper method. + */ + public function test_extract_zone_name() { + $method = new \ReflectionMethod($this->provider, 'extract_zone_name'); + $method->setAccessible(true); + + // Standard TLD + $this->assertEquals('example.com', $method->invoke($this->provider, 'www.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'sub.test.example.com')); + $this->assertEquals('example.com', $method->invoke($this->provider, 'example.com')); + + // Multi-part TLDs + $this->assertEquals('example.co.uk', $method->invoke($this->provider, 'www.example.co.uk')); + $this->assertEquals('example.com.au', $method->invoke($this->provider, 'sub.example.com.au')); + $this->assertEquals('example.co.nz', $method->invoke($this->provider, 'www.example.co.nz')); + } + + /** + * Test get_dns_records returns WP_Error when not configured. + */ + public function test_get_dns_records_not_configured() { + $result = $this->provider->get_dns_records('example.com'); + + // Without proper API credentials, should return WP_Error + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test create_dns_record returns WP_Error when not configured. + */ + public function test_create_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.1', + 'ttl' => 3600, + ]; + + $result = $this->provider->create_dns_record('example.com', $record); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test update_dns_record returns WP_Error when not configured. + */ + public function test_update_dns_record_not_configured() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.2', + 'ttl' => 3600, + ]; + + $result = $this->provider->update_dns_record('example.com', 'record_1', $record); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test delete_dns_record returns WP_Error when not configured. + */ + public function test_delete_dns_record_not_configured() { + $result = $this->provider->delete_dns_record('example.com', 'record_1'); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test is_dns_enabled default value. + */ + public function test_is_dns_enabled_default() { + $result = $this->provider->is_dns_enabled(); + $this->assertIsBool($result); + } + + /** + * Test enable_dns and disable_dns toggle. + */ + public function test_enable_disable_dns() { + $this->provider->enable_dns(); + $this->assertTrue($this->provider->is_dns_enabled()); + + $this->provider->disable_dns(); + $this->assertFalse($this->provider->is_dns_enabled()); + } + + /** + * Test provider ID is correct. + */ + public function test_provider_id() { + $this->assertEquals('hestia', $this->provider->get_id()); + } + + /** + * Test provider title. + */ + public function test_provider_title() { + $title = $this->provider->get_title(); + $this->assertStringContainsString('Hestia', $title); + } + + /** + * Test Hestia-specific record data structure. + */ + public function test_hestia_record_structure() { + // Test that record array has expected keys + $record = [ + 'type' => 'MX', + 'name' => '@', + 'content' => 'mail.example.com', + 'ttl' => 3600, + 'priority' => 10, + ]; + + $this->assertArrayHasKey('type', $record); + $this->assertArrayHasKey('name', $record); + $this->assertArrayHasKey('content', $record); + $this->assertArrayHasKey('priority', $record); + } + + /** + * Test root domain indicator (@). + */ + public function test_root_domain_indicator() { + $record = [ + 'type' => 'A', + 'name' => '@', + 'content' => '192.168.1.1', + ]; + + $this->assertEquals('@', $record['name']); + } +} diff --git a/tests/WP_Ultimo/Managers/DNS_Record_Manager_Test.php b/tests/WP_Ultimo/Managers/DNS_Record_Manager_Test.php new file mode 100644 index 00000000..bb552d6d --- /dev/null +++ b/tests/WP_Ultimo/Managers/DNS_Record_Manager_Test.php @@ -0,0 +1,329 @@ +manager = DNS_Record_Manager::get_instance(); + } + + /** + * Test singleton instance. + */ + public function test_singleton_instance() { + $instance1 = DNS_Record_Manager::get_instance(); + $instance2 = DNS_Record_Manager::get_instance(); + + $this->assertSame($instance1, $instance2); + $this->assertInstanceOf(DNS_Record_Manager::class, $instance1); + } + + /** + * Test get_allowed_record_types returns array. + */ + public function test_get_allowed_record_types() { + $types = $this->manager->get_allowed_record_types(); + + $this->assertIsArray($types); + $this->assertContains('A', $types); + $this->assertContains('AAAA', $types); + $this->assertContains('CNAME', $types); + $this->assertContains('MX', $types); + $this->assertContains('TXT', $types); + } + + /** + * Test get_dns_provider returns null when no provider configured. + */ + public function test_get_dns_provider_returns_null_when_not_configured() { + // When no provider is enabled, should return null + $provider = $this->manager->get_dns_provider(); + + // This may or may not be null depending on test environment + // At minimum, check it doesn't throw an error + $this->assertTrue($provider === null || is_object($provider)); + } + + /** + * Test get_dns_capable_providers returns array. + */ + public function test_get_dns_capable_providers() { + $providers = $this->manager->get_dns_capable_providers(); + + $this->assertIsArray($providers); + } + + /** + * Test validate_dns_record with valid A record. + */ + public function test_validate_dns_record_valid_a_record() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '192.168.1.1', + 'ttl' => 3600, + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertTrue($result); + } + + /** + * Test validate_dns_record with invalid type. + */ + public function test_validate_dns_record_invalid_type() { + $record = [ + 'type' => 'INVALID', + 'name' => 'test', + 'content' => 'value', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid-type', $result->get_error_code()); + } + + /** + * Test validate_dns_record with empty name. + */ + public function test_validate_dns_record_empty_name() { + $record = [ + 'type' => 'A', + 'name' => '', + 'content' => '192.168.1.1', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('empty-name', $result->get_error_code()); + } + + /** + * Test validate_dns_record with empty content. + */ + public function test_validate_dns_record_empty_content() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => '', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('empty-content', $result->get_error_code()); + } + + /** + * Test validate_dns_record with invalid IPv4. + */ + public function test_validate_dns_record_invalid_ipv4() { + $record = [ + 'type' => 'A', + 'name' => 'test', + 'content' => 'not-an-ip', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid-ipv4', $result->get_error_code()); + } + + /** + * Test validate_dns_record with invalid IPv6. + */ + public function test_validate_dns_record_invalid_ipv6() { + $record = [ + 'type' => 'AAAA', + 'name' => 'test', + 'content' => '192.168.1.1', // IPv4 instead of IPv6 + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid-ipv6', $result->get_error_code()); + } + + /** + * Test validate_dns_record with valid AAAA record. + */ + public function test_validate_dns_record_valid_aaaa() { + $record = [ + 'type' => 'AAAA', + 'name' => 'test', + 'content' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertTrue($result); + } + + /** + * Test validate_dns_record with valid CNAME. + */ + public function test_validate_dns_record_valid_cname() { + $record = [ + 'type' => 'CNAME', + 'name' => 'www', + 'content' => 'example.com', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertTrue($result); + } + + /** + * Test validate_dns_record with invalid CNAME hostname. + */ + public function test_validate_dns_record_invalid_cname() { + $record = [ + 'type' => 'CNAME', + 'name' => 'www', + 'content' => 'not a valid hostname!', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid-hostname', $result->get_error_code()); + } + + /** + * Test validate_dns_record with valid MX record. + */ + public function test_validate_dns_record_valid_mx() { + $record = [ + 'type' => 'MX', + 'name' => '@', + 'content' => 'mail.example.com', + 'priority' => 10, + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertTrue($result); + } + + /** + * Test validate_dns_record with valid TXT record. + */ + public function test_validate_dns_record_valid_txt() { + $record = [ + 'type' => 'TXT', + 'name' => '@', + 'content' => 'v=spf1 include:_spf.google.com ~all', + ]; + + $result = $this->invoke_private_method($this->manager, 'validate_dns_record', [$record]); + + $this->assertTrue($result); + } + + /** + * Test export_to_bind format. + */ + public function test_export_to_bind() { + $records = [ + new \WP_Ultimo\Integrations\Host_Providers\DNS_Record([ + 'type' => 'A', + 'name' => '@', + 'content' => '192.168.1.1', + 'ttl' => 3600, + ]), + new \WP_Ultimo\Integrations\Host_Providers\DNS_Record([ + 'type' => 'MX', + 'name' => '@', + 'content' => 'mail.example.com', + 'ttl' => 3600, + 'priority' => 10, + ]), + ]; + + $bind = $this->manager->export_to_bind($records, 'example.com'); + + $this->assertStringContainsString('$ORIGIN example.com.', $bind); + $this->assertStringContainsString('@ 3600 IN A 192.168.1.1', $bind); + $this->assertStringContainsString('@ 3600 IN MX 10 mail.example.com.', $bind); + } + + /** + * Test parse_bind_format. + */ + public function test_parse_bind_format() { + $bind_content = <<manager->parse_bind_format($bind_content); + + $this->assertIsArray($records); + $this->assertGreaterThanOrEqual(4, count($records)); + + // Find A record + $a_record = array_filter($records, fn($r) => $r['type'] === 'A'); + $this->assertNotEmpty($a_record); + } + + /** + * Test customer_can_manage_dns returns false for non-owner. + */ + public function test_customer_can_manage_dns_non_owner() { + // Create a test user who doesn't own any domains + $user_id = $this->factory->user->create(); + + $result = $this->manager->customer_can_manage_dns($user_id, 'example.com'); + + $this->assertFalse($result); + } + + /** + * Helper method to invoke private/protected methods for testing. + * + * @param object $object The object instance. + * @param string $method The method name. + * @param array $parameters Parameters to pass. + * @return mixed The method result. + */ + private function invoke_private_method($object, string $method, array $parameters = []) { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($method); + $method->setAccessible(true); + + return $method->invokeArgs($object, $parameters); + } +} diff --git a/views/dashboard-widgets/domain-mapping.php b/views/dashboard-widgets/domain-mapping.php index bceb56f9..dc86b02e 100644 --- a/views/dashboard-widgets/domain-mapping.php +++ b/views/dashboard-widgets/domain-mapping.php @@ -68,6 +68,21 @@ $second_row_actions = []; + // Check if DNS management is available + $dns_manager = \WP_Ultimo\Managers\DNS_Record_Manager::get_instance(); + $dns_provider = $dns_manager->get_dns_provider(); + $can_manage_dns = $dns_manager->customer_can_manage_dns(get_current_user_id(), $item->get_domain()); + + if ($dns_provider || wu_get_setting('enable_customer_dns_management', false)) { + $second_row_actions['manage_dns'] = [ + 'wrapper_classes' => 'wubox', + 'icon' => 'dashicons-wu-globe wu-align-middle wu-mr-1', + 'label' => '', + 'url' => wu_get_form_url('user_manage_dns_records', ['domain_id' => $item->get_id()]), + 'value' => __('DNS Records', 'ultimate-multisite'), + ]; + } + if ( ! $item->is_primary_domain()) { $second_row_actions['make_primary'] = [ 'wrapper_classes' => 'wubox', diff --git a/views/domain/admin-dns-management.php b/views/domain/admin-dns-management.php new file mode 100644 index 00000000..9a24db12 --- /dev/null +++ b/views/domain/admin-dns-management.php @@ -0,0 +1,216 @@ + + + + + + + + + + ' . esc_html($provider_name) . '' + ); + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ error }} + + + + + + + + + + + + + + + + + + {{ record.type }} + + + + + {{ record.name }} + + + {{ truncateContent(record.content, 40) }} + + (Priority: {{ record.priority }}) + + + + {{ formatTTL(record.ttl) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ dns.host }} + {{ dns.type }} + {{ dns.data }} + {{ dns.ttl }} + + + + {{ dns.host }} + {{ dns.type }} + {{ dns.data }} + {{ dns.ttl }} + + + + {{ dns.host }} + {{ dns.type }} + {{ dns.data }} + {{ dns.ttl }} + + + + + {{ results.network_ip }} + + + + + + + diff --git a/views/domain/dns-management-modal.php b/views/domain/dns-management-modal.php new file mode 100644 index 00000000..ebf52e0a --- /dev/null +++ b/views/domain/dns-management-modal.php @@ -0,0 +1,153 @@ + + + + + ' . esc_html($domain->get_domain()) . '' + ); + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ error }} + + + + + + + + + + + + + + + + + + {{ record.type }} + + + + + {{ record.name }} + + + {{ truncateContent(record.content, 40) }} + + (Priority: {{ record.priority }}) + + + + {{ formatTTL(record.ttl) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/views/domain/dns-record-form.php b/views/domain/dns-record-form.php new file mode 100644 index 00000000..e2ce104b --- /dev/null +++ b/views/domain/dns-record-form.php @@ -0,0 +1,231 @@ + + + $record['type'] ?? 'A', + 'proxied' => $record['proxied'] ?? false, + ] +); +?> +'> + + + + + + + + + + + + + + + + + + * + + + + > + + > + + __('IPv4 Address', 'ultimate-multisite'), + 'AAAA' => __('IPv6 Address', 'ultimate-multisite'), + 'CNAME' => __('Alias/Canonical Name', 'ultimate-multisite'), + 'MX' => __('Mail Exchange', 'ultimate-multisite'), + 'TXT' => __('Text Record', 'ultimate-multisite'), + ]; + if (isset($descriptions[ $type ])) { + echo ' - ' . esc_html($descriptions[ $type ]); + } + ?> + + + + + + + + + + + + + + + * + + + + + + + + + + . + + + + + + + + + + + * + + + + + + + + + + + + + + + + + + + + * + + + + + + + + + + + + + + + + + + + + + + + > + > + > + > + > + + + + + + + + + + + + + + + + + + > + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + + + +