From 8b338135da8d9f5954f32aaa4025873ea10f2b23 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 27 May 2026 18:23:56 -0600 Subject: [PATCH] fix(domain): skip auto domain-record creation when site uses a checkout-form base domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In subdirectory multisite, an operator can publish alternate base domains via the checkout form's site_url field (available_domains / available_domains_multi). When customers register subsites under that alternate base — e.g. example.com/ on a network whose primary host is wp.example.test — the previous check in Domain_Manager: $has_subdomain = str_replace($current_site->domain, '', $site->domain); treated the alternate base as a per-subsite custom mapping and produced a `primary_domain = true` Domain record for the shared base against every newly registered subsite. That mapping then made Mercator/sunrise route ALL `/*` traffic to the single subsite owning that row and 404 every sibling. This change introduces Domain_Manager::is_network_base_domain() which recognises both the network primary AND any operator-configured checkout-form base domain, and uses it in handle_site_created() and handle_site_deleted() instead of the simple string test. Also adds two extension seams: - wu_should_create_domain_record_for_site (bool, \WP_Site) Suppresses the automatic Domain record from addons/integrations. - wu_checkout_form_base_domains (string[]) Lets integrations declare extra network-base hosts (e.g. hosts provisioned by a host provider) so they are also excluded. Includes regression tests in Domain_Manager_Test covering: - is_network_base_domain matches the network primary - case/www/scheme normalisation - filter-injected base domains are honoured - handle_site_created creates no Domain row for a checkout-form base - wu_should_create_domain_record_for_site short-circuits creation - handle_site_deleted enqueues no wu_remove_subdomain for a base - normalize_base_domain handles scheme/path/port/case/www inputs --- inc/managers/class-domain-manager.php | 228 +++++++++++++++- .../Managers/Domain_Manager_Test.php | 245 +++++++++++++++++- 2 files changed, 456 insertions(+), 17 deletions(-) diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 032df80c0..702fe873f 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -317,17 +317,22 @@ public function init_dns_record_manager(): void { * Triggers subdomain mapping events on site creation. * * @since 2.0.0 + * @since 2.12.1 Skips auto-creation of a mapped-domain record when the new + * site's domain is a network-base domain (the network primary + * or any operator-configured `available_domains` value from + * checkout `site_url` fields). Without this guard, subdirectory + * multisites that expose an alternate base domain via the + * checkout form (e.g. `example.com` alongside the network + * primary `wp.example.test`) would have every newly registered + * subsite produce a `primary_domain = true` mapping record for + * the shared base, breaking sibling subsites. * * @param \WP_Site $site The site being added. * @return void */ public function handle_site_created($site): void { - global $current_site; - - $has_subdomain = str_replace($current_site->domain, '', $site->domain); - - if ( ! $has_subdomain) { + if ($this->is_network_base_domain((string) $site->domain)) { return; } @@ -347,8 +352,208 @@ public function handle_site_created($site): void { wu_enqueue_async_action('wu_add_subdomain', $args, 'domain'); } - // Create a domain record for the site - $this->create_domain_record_for_site($site); + /** + * Allow integrations and addons to suppress the automatic creation of + * a per-site mapped-domain record. Returning false short-circuits the + * `create_domain_record_for_site()` call. + * + * @since 2.12.1 + * + * @param bool $create Whether to create a Domain record. Default true. + * @param \WP_Site $site The site being added. + */ + if (apply_filters('wu_should_create_domain_record_for_site', true, $site)) { + $this->create_domain_record_for_site($site); + } + } + + /** + * Checks if the given domain is one of the network's "base" domains. + * + * A base domain is one of: + * + * - The network primary domain (`$current_site->domain`), or + * - Any value listed in `available_domains` / `available_domains_multi` + * on a `site_url` field of any published checkout form. + * + * In both cases the host is shared across many subdirectory subsites and + * must NOT be auto-promoted to a per-site mapped-domain record (which + * would route all `/*` requests through Mercator/sunrise to the + * single subsite owning that mapping and 404 every sibling). + * + * Results are memoised per request to avoid re-querying checkout forms + * on every `wp_insert_site` / `wp_delete_site` callback. + * + * @since 2.12.1 + * + * @param string $domain The domain to test. + * @return bool + */ + public function is_network_base_domain(string $domain): bool { + + global $current_site; + + $normalized = $this->normalize_base_domain($domain); + + if ('' === $normalized) { + return false; + } + + $network_domain = isset($current_site->domain) + ? $this->normalize_base_domain((string) $current_site->domain) + : ''; + + if ('' !== $network_domain && $normalized === $network_domain) { + return true; + } + + $bases = $this->get_checkout_form_base_domains(); + + return in_array($normalized, $bases, true); + } + + /** + * Returns the list of operator-configured base domains drawn from active + * checkout forms' `site_url` field settings (both `available_domains` and + * `available_domains_multi`). + * + * The list is memoised for the duration of the request. Use the + * `wu_checkout_form_base_domains` filter to inject extra hosts (for + * example from a hosting integration that provisions multiple network + * base domains). + * + * @since 2.12.1 + * + * @return string[] Normalised lowercase hosts without `www.` or port. + */ + public function get_checkout_form_base_domains(): array { + + // Memoise the (expensive) checkout-form scan per request, but keep the + // `wu_checkout_form_base_domains` filter responsive on every call so + // addons (and tests) can override the list without restarting the + // request. + static $form_domains = null; + + if (null === $form_domains) { + $form_domains = $this->collect_checkout_form_base_domains(); + } + + /** + * Filter the list of network-base domains used to suppress automatic + * per-site mapped-domain records. + * + * Hosts should be normalised (lowercase, no scheme, no port, no + * leading `www.`). Anything not in this list (and not equal to the + * network primary) is treated as a per-subsite custom mapping and + * gets a Domain record auto-created on `wp_insert_site`. + * + * @since 2.12.1 + * + * @param string[] $domains Normalised lowercase hosts. + */ + $domains = (array) apply_filters('wu_checkout_form_base_domains', $form_domains); + + $normalized = []; + + foreach ($domains as $domain) { + $host = $this->normalize_base_domain((string) $domain); + + if ('' !== $host) { + $normalized[ $host ] = true; + } + } + + return array_keys($normalized); + } + + /** + * Scans every checkout form's `site_url` field settings and returns the + * union of `available_domains` and `available_domains_multi` values as + * a list of normalised hostnames. + * + * Extracted from `get_checkout_form_base_domains()` so the (potentially + * expensive) DB scan can be memoised independently from the always-fresh + * filter pass. + * + * @since 2.12.1 + * + * @return string[] Normalised lowercase hosts. + */ + protected function collect_checkout_form_base_domains(): array { + + $domains = []; + + if ( ! function_exists('wu_get_checkout_forms')) { + return $domains; + } + + $forms = wu_get_checkout_forms(['number' => -1]); + + foreach ((array) $forms as $form) { + if ( ! is_object($form) || ! method_exists($form, 'get_all_fields_by_type')) { + continue; + } + + $site_url_fields = $form->get_all_fields_by_type('site_url'); + + foreach ((array) $site_url_fields as $field) { + foreach (['available_domains', 'available_domains_multi'] as $key) { + $raw = isset($field[ $key ]) ? (string) $field[ $key ] : ''; + + if ('' === $raw) { + continue; + } + + foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { + $host = $this->normalize_base_domain((string) $line); + + if ('' !== $host) { + $domains[ $host ] = true; + } + } + } + } + } + + return array_keys($domains); + } + + /** + * Normalises a domain string for base-domain comparison. + * + * Lowercases, trims whitespace, strips an optional scheme, removes a + * trailing path/dot, drops any port suffix, and removes a leading `www.` + * so callers can compare hosts symbolically without worrying about + * superficial formatting differences. + * + * @since 2.12.1 + * + * @param string $domain Raw domain or URL. + * @return string Empty string when the input cannot be parsed as a host. + */ + protected function normalize_base_domain(string $domain): string { + + $domain = strtolower(trim($domain)); + + if ('' === $domain) { + return ''; + } + + // Strip scheme/path so wp_parse_url gives us only the host. + if ( ! str_contains($domain, '://')) { + $domain = 'http://' . $domain; + } + + $host = wp_parse_url($domain, PHP_URL_HOST); + + if ( ! is_string($host) || '' === $host) { + return ''; + } + + $host = rtrim($host, '.'); + $host = preg_replace('/^www\./', '', $host); + + return (string) $host; } /** @@ -402,17 +607,16 @@ public function create_domain_record_for_site($site) { * Triggers subdomain mapping events on site deletion. * * @since 2.0.0 + * @since 2.12.1 Uses `is_network_base_domain()` so deletions of subdirectory + * subsites that share a network-base domain do not enqueue a + * `wu_remove_subdomain` job for the shared host. * * @param \WP_Site $site The site being removed. * @return void */ public function handle_site_deleted($site): void { - global $current_site; - - $has_subdomain = str_replace($current_site->domain, '', $site->domain); - - if ( ! $has_subdomain) { + if ($this->is_network_base_domain((string) $site->domain)) { return; } diff --git a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php index 4b26212cf..e0991fc3f 100644 --- a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php @@ -324,7 +324,7 @@ public function test_manager_is_singleton(): void { */ public function test_manager_slug(): void { $reflection = new \ReflectionClass($this->domain_manager); - $slug_prop = $reflection->getProperty('slug'); + $slug_prop = $reflection->getProperty('slug'); if (PHP_VERSION_ID < 80100) { $slug_prop->setAccessible(true); @@ -843,7 +843,7 @@ public function test_get_by_site_with_object(): void { wp_cache_flush(); - $site = get_blog_details($blog_id); + $site = get_blog_details($blog_id); $mappings = Domain::get_by_site($site); $this->assertNotFalse($mappings); @@ -1662,7 +1662,7 @@ public function test_get_domain_mapping_instructions_from_settings(): void { */ public function test_date_created(): void { $domain = new Domain(); - $date = '2025-01-01 12:00:00'; + $date = '2025-01-01 12:00:00'; $domain->set_date_created($date); $this->assertEquals($date, $domain->get_date_created()); @@ -1790,7 +1790,7 @@ public function test_domain_stage_classes_for_all_stages(): void { */ public function test_domain_validation_rules(): void { $domain = new Domain(); - $rules = $domain->validation_rules(); + $rules = $domain->validation_rules(); $this->assertIsArray($rules); $this->assertArrayHasKey('blog_id', $rules); @@ -1851,7 +1851,7 @@ public function test_dns_check_interval_setting(): void { // Domain should still be in checking-dns stage (DNS won't resolve in test env) $fetched = wu_get_domain($domain->get_id()); - $stage = $fetched->get_stage(); + $stage = $fetched->get_stage(); // It should either still be checking-dns (retry scheduled) or failed (if tries exceeded) $this->assertContains($stage, [Domain_Stage::CHECKING_DNS, Domain_Stage::FAILED]); @@ -2541,4 +2541,239 @@ public function test_auto_promote_on_done_without_ssl(): void { 'A custom domain reaching done-without-ssl should also be auto-promoted.' ); } + + // ---------------------------------------------------------------- + // NEW: is_network_base_domain + checkout-form-available-domains guard + // + // Regression: in subdirectory multisite, an operator can publish an + // alternate base domain via the checkout form's site_url field + // (`available_domains` / `available_domains_multi`). When customers + // register subsites under that alternate base (e.g. `example.com/`), + // the old `str_replace($current_site->domain, '', $site->domain)` test + // in handle_site_created/handle_site_deleted treated the alternate base + // as a per-subsite custom mapping and produced a `primary_domain = true` + // Domain record for `example.com` against each newly registered subsite — + // which made Mercator/sunrise route ALL `example.com/*` traffic to that + // single subsite and 404 every sibling. + // ---------------------------------------------------------------- + + /** + * The network primary domain is always treated as a base domain. + */ + public function test_is_network_base_domain_matches_network_primary(): void { + global $current_site; + + $this->assertTrue( + $this->domain_manager->is_network_base_domain((string) $current_site->domain) + ); + } + + /** + * Hostnames are normalised before comparison: www.* and case differences + * must not change the verdict. + */ + public function test_is_network_base_domain_normalises_host(): void { + global $current_site; + + $upper = strtoupper((string) $current_site->domain); + $with_www = 'www.' . preg_replace('/^www\./i', '', (string) $current_site->domain); + + $this->assertTrue($this->domain_manager->is_network_base_domain($upper)); + $this->assertTrue($this->domain_manager->is_network_base_domain($with_www)); + } + + /** + * Foreign hosts that are not the network primary and not declared via the + * filter must be classified as per-site mapped domains (false). + */ + public function test_is_network_base_domain_rejects_unknown_host(): void { + add_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX); + + try { + $this->assertFalse( + $this->domain_manager->is_network_base_domain('totally-unrelated-host.test') + ); + } finally { + remove_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX); + } + } + + /** + * Hosts injected through the `wu_checkout_form_base_domains` filter are + * recognised as base domains. This filter is the public seam used by the + * production code path (checkout-form-derived domains feed into it via + * `get_checkout_form_base_domains()`). + */ + public function test_is_network_base_domain_honours_filter(): void { + $filter = function () { + return ['nambo.pro']; + }; + + add_filter('wu_checkout_form_base_domains', $filter, PHP_INT_MAX); + + try { + $this->assertTrue($this->domain_manager->is_network_base_domain('nambo.pro')); + $this->assertTrue($this->domain_manager->is_network_base_domain('NAMBO.PRO')); + $this->assertTrue($this->domain_manager->is_network_base_domain('www.nambo.pro')); + $this->assertTrue($this->domain_manager->is_network_base_domain('https://nambo.pro/')); + } finally { + remove_filter('wu_checkout_form_base_domains', $filter, PHP_INT_MAX); + } + } + + /** + * Regression: handle_site_created MUST NOT create a Domain record when + * the new site's domain is one of the operator-declared checkout-form + * base domains. Without this guard, every subdirectory subsite created + * under `nambo.pro` was getting a `primary_domain = true` mapping row + * for `nambo.pro`, which broke every sibling subsite. + */ + public function test_handle_site_created_skips_domain_record_for_checkout_form_base(): void { + $base_filter = function () { + return ['regression-base.test']; + }; + add_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX); + + // Build a synthetic WP_Site whose domain is exactly the operator + // base. We don't need a real blog row for this assertion — the + // guard runs before any DB write, and using a fake site keeps the + // test independent of blog factory quirks. + $blog_id = $this->create_test_blog(); + $site = (object) [ + 'blog_id' => $blog_id, + 'domain' => 'regression-base.test', + 'path' => '/some-tenant/', + ]; + + try { + $this->domain_manager->handle_site_created($site); + + $created = wu_get_domains([ + 'blog_id' => $blog_id, + 'domain' => 'regression-base.test', + 'number' => 1, + ]); + + $this->assertSame( + [], + $created, + 'handle_site_created must not create a mapped-domain record for a checkout-form base domain.' + ); + } finally { + remove_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX); + } + } + + /** + * The `wu_should_create_domain_record_for_site` filter can suppress the + * automatic Domain record even for non-base domains (escape hatch for + * integrations). + */ + public function test_handle_site_created_respects_should_create_filter(): void { + add_filter('wu_should_create_domain_record_for_site', '__return_false'); + // Force the base-domain list to empty so we exercise the filter, not the base check. + add_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX); + + $blog_id = $this->create_test_blog(); + $site = (object) [ + 'blog_id' => $blog_id, + 'domain' => 'addon-suppressed.test', + 'path' => '/', + ]; + + try { + $this->domain_manager->handle_site_created($site); + + $created = wu_get_domains([ + 'blog_id' => $blog_id, + 'domain' => 'addon-suppressed.test', + 'number' => 1, + ]); + + $this->assertSame( + [], + $created, + 'wu_should_create_domain_record_for_site=false must suppress automatic record creation.' + ); + } finally { + remove_all_filters('wu_should_create_domain_record_for_site'); + remove_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX); + } + } + + /** + * Handle_site_deleted must not enqueue wu_remove_subdomain for a site + * whose domain is a checkout-form base — that base is shared and the + * subdomain-removal hook would (incorrectly) instruct host providers to + * tear down the shared base. + */ + public function test_handle_site_deleted_skips_remove_for_checkout_form_base(): void { + $base_filter = function () { + return ['regression-base-delete.test']; + }; + add_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX); + + $blog_id = $this->create_test_blog(); + $site = (object) [ + 'blog_id' => $blog_id, + 'domain' => 'regression-base-delete.test', + 'path' => '/some-tenant/', + ]; + + wu_unschedule_all_actions('wu_remove_subdomain'); + + try { + $this->domain_manager->handle_site_deleted($site); + + $matching_actions = wu_get_scheduled_actions( + [ + 'hook' => 'wu_remove_subdomain', + 'status' => \ActionScheduler_Store::STATUS_PENDING, + 'args' => [ + 'subdomain' => $site->domain, + 'site_id' => $site->blog_id, + ], + 'per_page' => 5, + ], + 'ids' + ); + + $this->assertEmpty( + $matching_actions, + 'handle_site_deleted must not enqueue wu_remove_subdomain for a checkout-form base domain.' + ); + } finally { + remove_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX); + wu_unschedule_all_actions('wu_remove_subdomain'); + } + } + + /** + * Normalize_base_domain must accept a wide variety of formatting + * (scheme prefix, trailing path, trailing dot, port, casing, leading www.). + */ + public function test_normalize_base_domain_handles_varied_inputs(): void { + $reflection = new \ReflectionClass($this->domain_manager); + $method = $reflection->getMethod('normalize_base_domain'); + $method->setAccessible(true); + + $cases = [ + 'Example.COM' => 'example.com', + 'www.Example.com' => 'example.com', + 'http://example.com/path' => 'example.com', + 'https://example.com:8080/' => 'example.com', + 'example.com.' => 'example.com', + ' EXAMPLE.com ' => 'example.com', + '' => '', + ]; + + foreach ($cases as $input => $expected) { + $actual = $method->invoke($this->domain_manager, $input); + $this->assertSame( + $expected, + $actual, + "normalize_base_domain({$input}) should yield '{$expected}', got '{$actual}'" + ); + } + } }