From e68c9f4e215a811924ac0d337fb3ab0cd8ac9169 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 20 Aug 2026 23:17:46 -0600 Subject: [PATCH 1/3] feat(domain-mapping): add runtime URL rewriting --- docs/RUNTIME-URL-REWRITING.md | 96 +++ inc/class-sunrise.php | 6 + .../class-runtime-url-rewriter.php | 665 ++++++++++++++++++ .../Runtime_URL_Rewriter_Test.php | 262 +++++++ 4 files changed, 1029 insertions(+) create mode 100644 docs/RUNTIME-URL-REWRITING.md create mode 100644 inc/domain-mapping/class-runtime-url-rewriter.php create mode 100644 tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php diff --git a/docs/RUNTIME-URL-REWRITING.md b/docs/RUNTIME-URL-REWRITING.md new file mode 100644 index 000000000..53846f12e --- /dev/null +++ b/docs/RUNTIME-URL-REWRITING.md @@ -0,0 +1,96 @@ +# Runtime URL rewriting proof of concept + +Ultimate Multisite can present canonical production data through a different +environment URL without changing the database. The feature is opt-in and runs +only when the incoming request matches a configured target URL. + +`SUNRISE` must be enabled because multisite has to resolve the target hostname +before normal plugins load. Define constants in `wp-config.php`, or provide +environment variables to PHP, so the configuration exists before sunrise runs. + +## One network domain + +When `DOMAIN_CURRENT_SITE` contains the canonical hostname, only the target URL +is required: + +```php +define('WP_ULTIMO_RUNTIME_URL', 'https://staging.example.test'); +``` + +The same value can be provided as an environment variable: + +```text +WP_ULTIMO_RUNTIME_URL=https://staging.example.test +``` + +To declare both sides explicitly: + +```php +define('WP_ULTIMO_RUNTIME_URL_FROM', 'https://www.example.com'); +define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.test'); +``` + +## Multiple or mapped domains + +Use a source-to-target map when a network has independent mapped domains: + +```php +define( + 'WP_ULTIMO_RUNTIME_URL_MAP', + [ + 'https://www.example.com' => 'https://www.staging.example.test', + 'https://shop.example.org' => 'https://shop.staging.example.test', + ] +); +``` + +Environment variables cannot contain a PHP array, so use a JSON object: + +```text +WP_ULTIMO_RUNTIME_URL_MAP={"https://www.example.com":"https://www.staging.example.test","https://shop.example.org":"https://shop.staging.example.test"} +``` + +Paths and ports are supported on both sides. Source authorities, including any +port, must match the domain stored for the corresponding site or network. Paths +are case-sensitive. Longer source URLs are processed first so a mapped +subdirectory can override its network root. URLs containing credentials, query +strings, or fragments are rejected as invalid mapping configuration. + +## Runtime behavior + +For a request to a configured target URL, the proof of concept: + +1. Translates the hostname and path back to the canonical address during + `get_site_by_path()` and `get_network_by_path()`. +2. Rewrites core-generated site, network, content, plugin, theme, attachment, + canonical, login, feed, and redirect URLs. +3. Rewrites rendered post, excerpt, widget, block, embed, email, upload, image + source-set, attachment, and REST response values. +4. Handles ordinary, protocol-relative, JSON-escaped, and URL-encoded absolute + URLs. + +Bare domains are not replaced. This avoids changing email addresses and prose. +Database values are never updated. + +Additional integrations can register their own filters at sunrise time: + +```php +add_action( + 'wu_runtime_url_rewriter_register_filters', + function ($rewrite) { + add_filter('my_plugin_generated_html', $rewrite); + } +); +``` + +## Proof-of-concept limitations + +- Every independently routed site needs its own source-to-target mapping. Two + root-level sites cannot share one target hostname and path. +- Code that reads the database directly and bypasses WordPress filters may still + expose canonical URLs. +- Encodings other than the documented plain, JSON-escaped, and URL-encoded forms + may not be rewritten. +- Cached HTML generated before enabling the mapping must be purged. +- Configure staging email, payment, cron, indexing, object-cache, and access + protections separately. URL rewriting does not make a production clone safe. diff --git a/inc/class-sunrise.php b/inc/class-sunrise.php index 1e98bb74d..cda7458a6 100644 --- a/inc/class-sunrise.php +++ b/inc/class-sunrise.php @@ -157,6 +157,7 @@ public static function load_dependencies(): void { require_once __DIR__ . '/models/class-domain.php'; require_once __DIR__ . '/models/class-site.php'; require_once __DIR__ . '/domain-mapping/class-primary-domain.php'; + require_once __DIR__ . '/domain-mapping/class-runtime-url-rewriter.php'; require_once __DIR__ . '/class-domain-mapping.php'; require_once __DIR__ . '/traits/trait-wp-ultimo-settings-deprecated.php'; require_once __DIR__ . '/class-settings.php'; @@ -195,6 +196,11 @@ public static function load_domain_mapping(): void { if ($should_startup) { self::load_dependencies(); + /* + * Optional runtime-only environment URL rewriting. + */ + \WP_Ultimo\Domain_Mapping\Runtime_URL_Rewriter::get_instance(); + /* * Primary Domain capabilities */ diff --git a/inc/domain-mapping/class-runtime-url-rewriter.php b/inc/domain-mapping/class-runtime-url-rewriter.php new file mode 100644 index 000000000..c02e57862 --- /dev/null +++ b/inc/domain-mapping/class-runtime-url-rewriter.php @@ -0,0 +1,665 @@ +mappings = $this->get_configured_mappings(); + + if (empty($this->mappings)) { + return; + } + + $this->active_mapping = $this->find_request_mapping(); + + if (empty($this->active_mapping)) { + return; + } + + add_filter('pre_get_site_by_path', [$this, 'resolve_site'], 1, 5); + add_filter('pre_get_network_by_path', [$this, 'resolve_network'], 1, 5); + add_action('ms_loaded', [$this, 'register_rewrite_filters'], 999); + } + + /** + * Resolve an environment request against the canonical site address. + * + * @since 2.15.2 + * + * @param null|false|\WP_Site $site Site already resolved by an earlier filter. + * @param string $domain Requested domain. + * @param string $path Requested path. + * @param int|null $segments Suggested path segment count. + * @param string[] $paths Candidate paths. + * @return null|false|\WP_Site + */ + public function resolve_site($site, $domain, $path, $segments = null, $paths = []) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + + if (null !== $site || ! $this->request_matches_active_mapping($domain, $path)) { + return $site; + } + + $source_path = $this->translate_request_path($path, $this->active_mapping); + + remove_filter('pre_get_site_by_path', [$this, 'resolve_site'], 1); + $site = get_site_by_path($this->active_mapping['source']['authority'], $source_path); + add_filter('pre_get_site_by_path', [$this, 'resolve_site'], 1, 5); + + return $site; + } + + /** + * Resolve an environment request against the canonical network address. + * + * @since 2.15.2 + * + * @param null|false|\WP_Network $network Network already resolved by an earlier filter. + * @param string $domain Requested domain. + * @param string $path Requested path. + * @param int|null $segments Suggested path segment count. + * @param string[] $paths Candidate paths. + * @return null|false|\WP_Network + */ + public function resolve_network($network, $domain, $path, $segments = null, $paths = []) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + + if (null !== $network || ! $this->request_matches_active_mapping($domain, $path)) { + return $network; + } + + $source_path = $this->translate_request_path($path, $this->active_mapping); + + remove_filter('pre_get_network_by_path', [$this, 'resolve_network'], 1); + $network = get_network_by_path($this->active_mapping['source']['authority'], $source_path); + add_filter('pre_get_network_by_path', [$this, 'resolve_network'], 1, 5); + + return $network; + } + + /** + * Register filters covering core URL generation and rendered content. + * + * @since 2.15.2 + * @return void + */ + public function register_rewrite_filters(): void { + + $string_and_array_filters = [ + 'option_home', + 'option_siteurl', + 'home_url', + 'site_url', + 'network_home_url', + 'network_site_url', + 'content_url', + 'plugins_url', + 'includes_url', + 'theme_file_uri', + 'stylesheet_directory_uri', + 'template_directory_uri', + 'script_loader_src', + 'style_loader_src', + 'wp_get_attachment_url', + 'post_link', + 'page_link', + 'post_type_link', + 'attachment_link', + 'term_link', + 'author_link', + 'day_link', + 'month_link', + 'year_link', + 'feed_link', + 'get_canonical_url', + 'redirect_canonical', + 'wp_redirect', + 'login_url', + 'logout_url', + 'lostpassword_url', + 'register_url', + 'the_content', + 'the_excerpt', + 'the_content_feed', + 'the_excerpt_rss', + 'widget_text', + 'widget_text_content', + 'render_block', + 'oembed_result', + 'embed_oembed_html', + 'wp_audio_shortcode', + 'wp_video_shortcode', + 'retrieve_password_message', + 'wp_prepare_attachment_for_js', + 'wp_resource_hints', + 'wp_mail', + ]; + + foreach ($string_and_array_filters as $filter) { + add_filter($filter, [$this, 'rewrite_value'], PHP_INT_MAX); + } + + add_filter('upload_dir', [$this, 'rewrite_upload_directory'], PHP_INT_MAX); + add_filter('wp_calculate_image_srcset', [$this, 'rewrite_value'], PHP_INT_MAX); + add_filter('rest_post_dispatch', [$this, 'rewrite_rest_response'], PHP_INT_MAX, 3); + add_filter('allowed_redirect_hosts', [$this, 'allow_target_hosts'], PHP_INT_MAX); + + /** + * Fires after the proof-of-concept runtime URL filters are registered. + * + * @since 2.15.2 + * + * @param callable $callback URL and rendered-value rewrite callback. + * @param self $rewriter Runtime URL rewriter instance. + */ + do_action('wu_runtime_url_rewriter_register_filters', [$this, 'rewrite_value'], $this); + } + + /** + * Recursively rewrite strings in a rendered value. + * + * @since 2.15.2 + * + * @param mixed $value Filtered value. + * @return mixed + */ + public function rewrite_value($value) { + + if (is_string($value)) { + return $this->rewrite_string($value); + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[ $key ] = $this->rewrite_value($item); + } + } + + return $value; + } + + /** + * Rewrite configured URL forms in a string without touching bare domains. + * + * Absolute, protocol-relative, JSON-escaped, and URL-encoded forms are + * covered. Bare domains are deliberately ignored to avoid changing email + * addresses or unrelated text. + * + * @since 2.15.2 + * + * @param string $value String that may contain canonical URLs. + * @return string + */ + public function rewrite_string($value) { + + if ('' === $value) { + return $value; + } + + foreach ($this->mappings as $mapping) { + $target = $mapping['target']['authority'] . $mapping['target']['base_path']; + $absolute_target = $mapping['target']['scheme'] . '://' . $target; + $plain_source = preg_quote($mapping['source']['authority'], '#') + . '(?-i:' . preg_quote($mapping['source']['base_path'], '#') . ')'; + $plain_boundary = '(?=$|[/?\#\s"\'<>)\]},;&])'; + + $value = $this->replace_pattern( + $value, + '#https?://' . $plain_source . $plain_boundary . '#i', + $absolute_target + ); + $value = $this->replace_pattern( + $value, + '#(?)\]},;&])'; + + $value = $this->replace_pattern( + $value, + '#https?:\\\\/\\\\/' . $escaped_source . $escaped_boundary . '#i', + $escaped_target + ); + + $encoded_source = preg_quote(rawurlencode($mapping['source']['authority']), '#') + . $this->get_encoded_path_pattern($mapping['source']['base_path']); + $encoded_boundary = '(?=$|%2F|%3F|%23|%20|%22|%27|%3C|%3E|%29|%5D|%7D|%2C|%3B|%26|[/?\#\s"\'<>)\]},;&])'; + + $value = $this->replace_pattern( + $value, + '#https?%3A%2F%2F' . $encoded_source . $encoded_boundary . '#i', + rawurlencode($absolute_target) + ); + $value = $this->replace_pattern( + $value, + '#%2F%2F' . $encoded_source . $encoded_boundary . '#i', + rawurlencode('//' . $target) + ); + } + + return $value; + } + + /** + * Replace a URL pattern while treating the replacement as a literal string. + * + * @since 2.15.2 + * + * @param string $value Subject string. + * @param string $pattern Regular expression. + * @param string $replacement Literal replacement. + * @return string + */ + private function replace_pattern($value, $pattern, $replacement) { + + $result = preg_replace_callback( + $pattern, + static function () use ($replacement) { + + return $replacement; + }, + $value + ); + + return is_string($result) ? $result : $value; + } + + /** + * Build a pattern with case-sensitive path text and flexible percent escapes. + * + * @since 2.15.2 + * + * @param string $path URL path. + * @return string + */ + private function get_encoded_path_pattern($path) { + + $encoded = rawurlencode($path); + $parts = preg_split('/(%[0-9A-F]{2})/', $encoded, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + if (! is_array($parts)) { + return '(?-i:' . preg_quote($encoded, '#') . ')'; + } + + $pattern = ''; + + foreach ($parts as $part) { + if (1 === preg_match('/^%[0-9A-F]{2}$/', $part)) { + $pattern .= '(?i:' . preg_quote($part, '#') . ')'; + } else { + $pattern .= '(?-i:' . preg_quote($part, '#') . ')'; + } + } + + return $pattern; + } + + /** + * Rewrite upload URL fields without changing filesystem paths. + * + * @since 2.15.2 + * + * @param array $uploads Upload directory data. + * @return array + */ + public function rewrite_upload_directory($uploads) { + + foreach (['url', 'baseurl'] as $key) { + if (isset($uploads[ $key ])) { + $uploads[ $key ] = $this->rewrite_value($uploads[ $key ]); + } + } + + return $uploads; + } + + /** + * Rewrite URLs contained in a REST response payload. + * + * @since 2.15.2 + * + * @param mixed $response REST response. + * @param \WP_REST_Server $server REST server. + * @param \WP_REST_Request $request REST request. + * @return mixed + */ + public function rewrite_rest_response($response, $server, $request) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + + if ($response instanceof \WP_HTTP_Response) { + $response->set_data($this->rewrite_value($response->get_data())); + } + + return $response; + } + + /** + * Permit safe redirects to configured environment hosts. + * + * @since 2.15.2 + * + * @param string[] $hosts Allowed redirect hosts. + * @return string[] + */ + public function allow_target_hosts($hosts) { + + foreach ($this->mappings as $mapping) { + $hosts[] = $mapping['target']['host']; + } + + return array_values(array_unique($hosts)); + } + + /** + * Get normalized mappings for diagnostics and extensions. + * + * @since 2.15.2 + * @return array + */ + public function get_mappings() { + + return $this->mappings; + } + + /** + * Read supported constants and environment variables. + * + * @since 2.15.2 + * @return array + */ + private function get_configured_mappings() { + + $config = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP'); + + if (is_string($config) && '' !== $config) { + $decoded = json_decode($config, true); + $config = is_array($decoded) ? $decoded : []; + } + + if (! is_array($config)) { + $config = []; + } + + if (empty($config)) { + $target = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_TO'); + + if (empty($target)) { + $target = $this->get_config_value('WP_ULTIMO_RUNTIME_URL'); + } + + $source = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_FROM'); + + if (empty($source) && ! empty($target) && defined('DOMAIN_CURRENT_SITE')) { + $target_parts = $this->normalize_url($target); + $scheme = $target_parts ? $target_parts['scheme'] : 'https'; + $network_path = defined('PATH_CURRENT_SITE') ? PATH_CURRENT_SITE : '/'; + $source = $scheme . '://' . DOMAIN_CURRENT_SITE . $network_path; + } + + if (! empty($source) && ! empty($target)) { + $config = [$source => $target]; + } + } + + /** + * Filters runtime URL mappings before they are normalized. + * + * @since 2.15.2 + * + * @param array $config Source URL to target URL mappings. + */ + $config = apply_filters('wu_runtime_url_rewriter_mappings', $config); + + $mappings = []; + + foreach ($config as $source_url => $target_url) { + $source = $this->normalize_url($source_url); + $target = $this->normalize_url($target_url); + + if (! $source || ! $target) { + continue; + } + + $mappings[] = [ + 'source' => $source, + 'target' => $target, + ]; + } + + usort( + $mappings, + static function ($left, $right) { + + $left_length = strlen($left['source']['authority'] . $left['source']['base_path']); + $right_length = strlen($right['source']['authority'] . $right['source']['base_path']); + + return $right_length <=> $left_length; + } + ); + + return $mappings; + } + + /** + * Read a constant first, then an environment variable of the same name. + * + * @since 2.15.2 + * + * @param string $name Configuration name. + * @return mixed + */ + private function get_config_value($name) { + + if (defined($name)) { + return constant($name); + } + + if (function_exists('getenv')) { + $value = getenv($name); + + if (false !== $value) { + return $value; + } + } + + return null; + } + + /** + * Normalize a configured HTTP URL into routing components. + * + * @since 2.15.2 + * + * @param mixed $url Configured URL. + * @return array|false + */ + private function normalize_url($url) { + + if (! is_string($url) || '' === trim($url)) { + return false; + } + + $url = trim($url); + + if (! str_contains($url, '://')) { + $url = 'https://' . ltrim($url, '/'); + } + + // wp_parse_url() may not be available in every sunrise integration. + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $parts = parse_url($url); + + if ( + ! is_array($parts) + || empty($parts['host']) + || empty($parts['scheme']) + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['query']) + || isset($parts['fragment']) + ) { + return false; + } + + $scheme = strtolower($parts['scheme']); + + if (! in_array($scheme, ['http', 'https'], true)) { + return false; + } + + $host = strtolower($parts['host']); + $authority = $host . (isset($parts['port']) ? ':' . (int) $parts['port'] : ''); + $base_path = isset($parts['path']) ? '/' . trim($parts['path'], '/') : ''; + + return [ + 'scheme' => $scheme, + 'host' => $host, + 'authority' => $authority, + 'base_path' => '/' === $base_path ? '' : rtrim($base_path, '/'), + ]; + } + + /** + * Find the mapping represented by the current HTTP request. + * + * @since 2.15.2 + * @return array|null + */ + private function find_request_mapping() { + + $host = isset($_SERVER['HTTP_HOST']) ? strtolower(sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST']))) : ''; + // The request URI is parsed as a path and only compared with configured paths; preserving percent-encoding is required. + $path = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '/'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + if ('' === $host) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $parsed_path = parse_url($path, PHP_URL_PATH); + $path = is_string($parsed_path) ? $parsed_path : '/'; + $selected = null; + $best_length = -1; + + foreach ($this->mappings as $mapping) { + if (! $this->request_matches_mapping($host, $path, $mapping)) { + continue; + } + + $target_length = strlen($mapping['target']['authority'] . $mapping['target']['base_path']); + + if ($target_length > $best_length) { + $selected = $mapping; + $best_length = $target_length; + } + } + + return $selected; + } + + /** + * Check a bootstrap request against the active mapping. + * + * @since 2.15.2 + * + * @param string $domain Requested domain. + * @param string $path Requested path. + * @return bool + */ + private function request_matches_active_mapping($domain, $path) { + + return ! empty($this->active_mapping) + && $this->request_matches_mapping(strtolower($domain), $path, $this->active_mapping); + } + + /** + * Check a host and path against a target mapping. + * + * @since 2.15.2 + * + * @param string $host Request host, optionally including a port. + * @param string $path Request path. + * @param array $mapping Normalized mapping. + * @return bool + */ + private function request_matches_mapping($host, $path, $mapping) { + + if ($host !== $mapping['target']['authority']) { + return false; + } + + $target_path = $mapping['target']['base_path']; + + return '' === $target_path + || $path === $target_path + || str_starts_with($path, $target_path . '/'); + } + + /** + * Translate a target request path to its canonical source path. + * + * @since 2.15.2 + * + * @param string $path Target request path. + * @param array $mapping Normalized mapping. + * @return string + */ + private function translate_request_path($path, $mapping) { + + $target_path = $mapping['target']['base_path']; + $source_path = $mapping['source']['base_path']; + $relative = '' === $target_path ? $path : substr($path, strlen($target_path)); + $translated = '/' . trim($source_path . '/' . ltrim($relative, '/'), '/'); + + if ('/' !== $translated && str_ends_with($path, '/')) { + $translated .= '/'; + } + + return $translated; + } +} diff --git a/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php new file mode 100644 index 000000000..04929ad66 --- /dev/null +++ b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php @@ -0,0 +1,262 @@ + 'https://staging.example.test/Preview', + 'https://user:pass@invalid.example' => 'https://ignored.example.test', + 'https://invalid-query.example/?key=1' => 'https://ignored-query.example.test', + 'https://invalid-target.example' => 'https://ignored-target.example.test/#fragment', + ]; + }; + + add_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + require_once dirname(__DIR__, 3) . '/inc/domain-mapping/class-runtime-url-rewriter.php'; + + self::$rewriter = Runtime_URL_Rewriter::get_instance(); + + remove_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + } + + /** + * Restore request state and early hooks. + */ + public static function tear_down_after_class() { + + remove_filter('pre_get_site_by_path', [self::$rewriter, 'resolve_site'], 1); + remove_filter('pre_get_network_by_path', [self::$rewriter, 'resolve_network'], 1); + remove_action('ms_loaded', [self::$rewriter, 'register_rewrite_filters'], 999); + + if (null === self::$previous_http_host) { + unset($_SERVER['HTTP_HOST']); + } else { + $_SERVER['HTTP_HOST'] = self::$previous_http_host; + } + + if (null === self::$previous_request_uri) { + unset($_SERVER['REQUEST_URI']); + } else { + $_SERVER['REQUEST_URI'] = self::$previous_request_uri; + } + + parent::tear_down_after_class(); + } + + /** + * Invalid URL components are rejected during normalization. + */ + public function test_only_valid_mapping_is_loaded() { + + $mappings = self::$rewriter->get_mappings(); + + $this->assertCount(1, $mappings); + $this->assertSame('example.org', $mappings[0]['source']['authority']); + $this->assertSame('staging.example.test', $mappings[0]['target']['host']); + $this->assertSame('/Preview', $mappings[0]['target']['base_path']); + } + + /** + * The active request registers both early multisite routing filters. + */ + public function test_early_routing_filters_are_registered() { + + $mapping_filter = static function () { + + return ['https://example.org' => 'https://staging.example.test/Preview']; + }; + + add_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + self::$rewriter->init(); + + remove_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $this->assertSame(1, has_filter('pre_get_site_by_path', [self::$rewriter, 'resolve_site'])); + $this->assertSame(1, has_filter('pre_get_network_by_path', [self::$rewriter, 'resolve_network'])); + } + + /** + * Rewrite supported absolute, relative, escaped, and encoded URL forms. + */ + public function test_rewrites_supported_url_forms() { + + $cases = [ + 'https://EXAMPLE.ORG/site/' => 'https://staging.example.test/Preview/site/', + 'http://example.org/site/' => 'https://staging.example.test/Preview/site/', + '//example.org/site/' => '//staging.example.test/Preview/site/', + 'https:\/\/example.org\/asset.jpg' => 'https:\/\/staging.example.test\/Preview\/asset.jpg', + 'https%3a%2f%2fEXAMPLE.ORG%2fapi%2Fitem' => 'https%3A%2F%2Fstaging.example.test%2FPreview%2fapi%2Fitem', + ]; + + foreach ($cases as $input => $expected) { + $this->assertSame($expected, self::$rewriter->rewrite_string($input), $input); + } + } + + /** + * Avoid partial authorities, ports, case-mismatched paths, and bare domains. + */ + public function test_does_not_rewrite_unsafe_partial_matches() { + + $unchanged = [ + 'https://example.org:8443/site/', + 'https://example.org.evil/site/', + 'https://example.orgish/site/', + 'admin@example.org', + ]; + + foreach ($unchanged as $value) { + $this->assertSame($value, self::$rewriter->rewrite_string($value), $value); + } + } + + /** + * Paths remain case-sensitive while authorities remain case-insensitive. + */ + public function test_source_path_matching_is_case_sensitive() { + + $mapping_filter = static function () { + + return ['https://example.org/Store' => 'https://staging.example.test/Shop']; + }; + + add_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $reflection = new \ReflectionClass(Runtime_URL_Rewriter::class); + $method = $reflection->getMethod('get_configured_mappings'); + $mappings = $method->invoke(self::$rewriter); + + remove_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $property = $reflection->getProperty('mappings'); + $original = $property->getValue(self::$rewriter); + + $property->setValue(self::$rewriter, $mappings); + + try { + $this->assertSame( + 'https://staging.example.test/Shop/item', + self::$rewriter->rewrite_string('https://EXAMPLE.ORG/Store/item') + ); + $this->assertSame( + 'https://example.org/store/item', + self::$rewriter->rewrite_string('https://example.org/store/item') + ); + } finally { + $property->setValue(self::$rewriter, $original); + } + } + + /** + * Recursive and upload-directory rewriting leaves filesystem paths alone. + */ + public function test_rewrites_nested_values_and_upload_urls() { + + $value = [ + 'url' => 'https://example.org/file.jpg', + 'nested' => ['https://example.org/page/'], + ]; + + $this->assertSame( + [ + 'url' => 'https://staging.example.test/Preview/file.jpg', + 'nested' => ['https://staging.example.test/Preview/page/'], + ], + self::$rewriter->rewrite_value($value) + ); + + $uploads = self::$rewriter->rewrite_upload_directory( + [ + 'path' => '/var/www/uploads', + 'basedir' => '/var/www/uploads', + 'url' => 'https://example.org/uploads/file.jpg', + 'baseurl' => 'https://example.org/uploads', + ] + ); + + $this->assertSame('/var/www/uploads', $uploads['path']); + $this->assertSame('/var/www/uploads', $uploads['basedir']); + $this->assertSame('https://staging.example.test/Preview/uploads/file.jpg', $uploads['url']); + $this->assertSame('https://staging.example.test/Preview/uploads', $uploads['baseurl']); + } + + /** + * Resolve the environment root against the canonical multisite records. + */ + public function test_resolves_canonical_site_and_network() { + + $site = self::$rewriter->resolve_site(null, 'staging.example.test', '/Preview/'); + $network = self::$rewriter->resolve_network(null, 'staging.example.test', '/Preview/'); + + $this->assertInstanceOf(\WP_Site::class, $site); + $this->assertSame('example.org', $site->domain); + $this->assertSame('/', $site->path); + $this->assertInstanceOf(\WP_Network::class, $network); + $this->assertSame('example.org', $network->domain); + $this->assertSame('/', $network->path); + } + + /** + * REST payloads and redirect hosts are rewritten consistently. + */ + public function test_rewrites_rest_response_and_allows_target_host() { + + $response = new \WP_REST_Response(['url' => 'https://example.org/api/item']); + $result = self::$rewriter->rewrite_rest_response($response, null, null); + + $this->assertSame( + ['url' => 'https://staging.example.test/Preview/api/item'], + $result->get_data() + ); + $this->assertSame( + ['existing.example', 'staging.example.test'], + self::$rewriter->allow_target_hosts(['existing.example']) + ); + } +} From ab9a7dd92abfe7060754dda4fdfdfad1932870fc Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 21 Aug 2026 11:41:16 -0600 Subject: [PATCH 2/3] feat(domain-mapping): load runtime maps from file --- docs/RUNTIME-URL-REWRITING.md | 35 ++++++++++ .../class-runtime-url-rewriter.php | 68 ++++++++++++++++--- .../Runtime_URL_Rewriter_Test.php | 56 +++++++++++++++ 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/docs/RUNTIME-URL-REWRITING.md b/docs/RUNTIME-URL-REWRITING.md index 53846f12e..478ac3a74 100644 --- a/docs/RUNTIME-URL-REWRITING.md +++ b/docs/RUNTIME-URL-REWRITING.md @@ -50,6 +50,41 @@ Environment variables cannot contain a PHP array, so use a JSON object: WP_ULTIMO_RUNTIME_URL_MAP={"https://www.example.com":"https://www.staging.example.test","https://shop.example.org":"https://shop.staging.example.test"} ``` +## Large domain sets + +For hundreds of domains, keep the mappings in a dedicated JSON file instead of +putting the complete array in `wp-config.php`. Only the absolute file path needs +to be configured: + +```php +define( + 'WP_ULTIMO_RUNTIME_URL_MAP_FILE', + '/etc/ultimate-multisite/runtime-url-map.json' +); +``` + +The path can instead be supplied as an environment variable, which avoids any +`wp-config.php` change: + +```text +WP_ULTIMO_RUNTIME_URL_MAP_FILE=/etc/ultimate-multisite/runtime-url-map.json +``` + +The JSON file is a source-to-target object: + +```json +{ + "https://customer-one.example": "https://customer-one.staging.example.test", + "https://customer-two.example": "https://customer-two.staging.example.test" +} +``` + +Store the file outside the public web root and make it readable by PHP. It is +loaded during Sunrise, so remote URLs are not supported. Missing, unreadable, +or malformed files fail closed without enabling runtime rewriting. Inline +`WP_ULTIMO_RUNTIME_URL_MAP` entries can be used as overrides; when both sources +contain the same canonical URL, the inline entry wins. + Paths and ports are supported on both sides. Source authorities, including any port, must match the domain stored for the corresponding site or network. Paths are case-sensitive. Longer source URLs are processed first so a mapped diff --git a/inc/domain-mapping/class-runtime-url-rewriter.php b/inc/domain-mapping/class-runtime-url-rewriter.php index c02e57862..ab77fcc7e 100644 --- a/inc/domain-mapping/class-runtime-url-rewriter.php +++ b/inc/domain-mapping/class-runtime-url-rewriter.php @@ -414,16 +414,11 @@ public function get_mappings() { */ private function get_configured_mappings() { - $config = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP'); - - if (is_string($config) && '' !== $config) { - $decoded = json_decode($config, true); - $config = is_array($decoded) ? $decoded : []; - } - - if (! is_array($config)) { - $config = []; - } + $file_config = $this->get_file_configured_mappings(); + $inline_config = $this->decode_mapping_config( + $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP') + ); + $config = array_merge($file_config, $inline_config); if (empty($config)) { $target = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_TO'); @@ -485,6 +480,59 @@ static function ($left, $right) { return $mappings; } + /** + * Read mappings from a JSON file for installations with large domain sets. + * + * Inline mappings take precedence when the same source URL exists in both + * configuration sources. + * + * @since 2.15.2 + * @return array + */ + private function get_file_configured_mappings() { + + $file = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP_FILE'); + + if (! is_string($file) || '' === trim($file)) { + return []; + } + + $file = trim($file); + + if (! is_file($file) || ! is_readable($file)) { + return []; + } + + // The configured local file must be available before WordPress is fully loaded. + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents($file); + + return false === $contents ? [] : $this->decode_mapping_config($contents); + } + + /** + * Decode a PHP array or JSON object containing source-to-target mappings. + * + * @since 2.15.2 + * + * @param mixed $config Mapping configuration. + * @return array + */ + private function decode_mapping_config($config) { + + if (is_array($config)) { + return $config; + } + + if (! is_string($config) || '' === trim($config)) { + return []; + } + + $decoded = json_decode($config, true); + + return is_array($decoded) ? $decoded : []; + } + /** * Read a constant first, then an environment variable of the same name. * diff --git a/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php index 04929ad66..02e7af863 100644 --- a/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php @@ -226,6 +226,62 @@ public function test_rewrites_nested_values_and_upload_urls() { $this->assertSame('https://staging.example.test/Preview/uploads', $uploads['baseurl']); } + /** + * Large mapping sets can be loaded from JSON with optional inline overrides. + */ + public function test_loads_large_mapping_set_from_json_file() { + + $file = wp_tempnam('runtime-url-map.json'); + $this->assertIsString($file); + + $file_mappings = []; + + for ($index = 1; $index <= 250; $index++) { + $file_mappings["https://customer-{$index}.example"] = "https://customer-{$index}.staging.example.test"; + } + + // Direct file and environment operations intentionally exercise pre-WordPress configuration. + // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv, WordPress.WP.AlternativeFunctions.unlink_unlink + $this->assertNotFalse(file_put_contents($file, wp_json_encode($file_mappings))); + + $previous_file = getenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE'); + $previous_inline = getenv('WP_ULTIMO_RUNTIME_URL_MAP'); + + putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE=' . $file); + putenv('WP_ULTIMO_RUNTIME_URL_MAP={"https://customer-42.example":"https://override.staging.example.test"}'); + + try { + $reflection = new \ReflectionClass(Runtime_URL_Rewriter::class); + $method = $reflection->getMethod('get_configured_mappings'); + $mappings = $method->invoke(self::$rewriter); + } finally { + false === $previous_file + ? putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE') + : putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE=' . $previous_file); + false === $previous_inline + ? putenv('WP_ULTIMO_RUNTIME_URL_MAP') + : putenv('WP_ULTIMO_RUNTIME_URL_MAP=' . $previous_inline); + unlink($file); + } + // phpcs:enable + + $targets_by_source = []; + + foreach ($mappings as $mapping) { + $targets_by_source[ $mapping['source']['authority'] ] = $mapping['target']['authority']; + } + + $this->assertCount(250, $mappings); + $this->assertSame( + 'override.staging.example.test', + $targets_by_source['customer-42.example'] + ); + $this->assertSame( + 'customer-250.staging.example.test', + $targets_by_source['customer-250.example'] + ); + } + /** * Resolve the environment root against the canonical multisite records. */ From bce3f6a47dadf736c1bb740d2c41ced04f39d988 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 21 Aug 2026 14:58:41 -0600 Subject: [PATCH 3/3] feat(domain-mapping): apply runtime rules to subdomains --- docs/RUNTIME-URL-REWRITING.md | 77 ++--- .../class-runtime-url-rewriter.php | 286 +++++++++++------- .../Runtime_URL_Rewriter_Test.php | 167 +++++++--- 3 files changed, 340 insertions(+), 190 deletions(-) diff --git a/docs/RUNTIME-URL-REWRITING.md b/docs/RUNTIME-URL-REWRITING.md index 478ac3a74..4848c30fa 100644 --- a/docs/RUNTIME-URL-REWRITING.md +++ b/docs/RUNTIME-URL-REWRITING.md @@ -26,70 +26,58 @@ WP_ULTIMO_RUNTIME_URL=https://staging.example.test To declare both sides explicitly: ```php -define('WP_ULTIMO_RUNTIME_URL_FROM', 'https://www.example.com'); -define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.test'); +define('WP_ULTIMO_RUNTIME_URL_FROM', 'https://example.com'); +define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.com'); ``` -## Multiple or mapped domains +## Domain suffix mappings -Use a source-to-target map when a network has independent mapped domains: +A configured domain is a suffix rule. One root mapping automatically applies to +the root and every subdomain while preserving all leading labels: ```php -define( - 'WP_ULTIMO_RUNTIME_URL_MAP', - [ - 'https://www.example.com' => 'https://www.staging.example.test', - 'https://shop.example.org' => 'https://shop.staging.example.test', - ] -); +define('WP_ULTIMO_RUNTIME_URL_FROM', 'https://example.com'); +define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.com'); ``` -Environment variables cannot contain a PHP array, so use a JSON object: - -```text -WP_ULTIMO_RUNTIME_URL_MAP={"https://www.example.com":"https://www.staging.example.test","https://shop.example.org":"https://shop.staging.example.test"} -``` +That single rule produces mappings such as: -## Large domain sets +| Canonical URL | Environment URL | +|---|---| +| `https://example.com` | `https://staging.example.com` | +| `https://customer-one.example.com` | `https://customer-one.staging.example.com` | +| `https://site.example.com` | `https://site.staging.example.com` | +| `https://deep.site.example.com` | `https://deep.site.staging.example.com` | -For hundreds of domains, keep the mappings in a dedicated JSON file instead of -putting the complete array in `wp-config.php`. Only the absolute file path needs -to be configured: +No per-site configuration is required. Use a source-to-target map only when a +network contains multiple unrelated root domains: ```php define( - 'WP_ULTIMO_RUNTIME_URL_MAP_FILE', - '/etc/ultimate-multisite/runtime-url-map.json' + 'WP_ULTIMO_RUNTIME_URL_MAP', + [ + 'https://example.com' => 'https://staging.example.com', + 'https://example.org' => 'https://staging.example.org', + ] ); ``` -The path can instead be supplied as an environment variable, which avoids any -`wp-config.php` change: +Environment variables cannot contain a PHP array, so use a JSON object: ```text -WP_ULTIMO_RUNTIME_URL_MAP_FILE=/etc/ultimate-multisite/runtime-url-map.json -``` - -The JSON file is a source-to-target object: - -```json -{ - "https://customer-one.example": "https://customer-one.staging.example.test", - "https://customer-two.example": "https://customer-two.staging.example.test" -} +WP_ULTIMO_RUNTIME_URL_MAP={"https://example.com":"https://staging.example.com","https://example.org":"https://staging.example.org"} ``` -Store the file outside the public web root and make it readable by PHP. It is -loaded during Sunrise, so remote URLs are not supported. Missing, unreadable, -or malformed files fail closed without enabling runtime rewriting. Inline -`WP_ULTIMO_RUNTIME_URL_MAP` entries can be used as overrides; when both sources -contain the same canonical URL, the inline entry wins. +More specific child rules override a parent suffix rule. For example, an +explicit `vip.example.com` rule is selected before the broader `example.com` +rule. The child rule also applies to its own subdomains. Paths and ports are supported on both sides. Source authorities, including any -port, must match the domain stored for the corresponding site or network. Paths -are case-sensitive. Longer source URLs are processed first so a mapped -subdirectory can override its network root. URLs containing credentials, query -strings, or fragments are rejected as invalid mapping configuration. +preserved subdomain labels and port, must match the domain stored for the +corresponding site or network. Paths are case-sensitive. Longer source URLs are +processed first so a mapped child domain or subdirectory can override its +parent rule. URLs containing credentials, query strings, or fragments are +rejected as invalid mapping configuration. ## Runtime behavior @@ -120,8 +108,7 @@ add_action( ## Proof-of-concept limitations -- Every independently routed site needs its own source-to-target mapping. Two - root-level sites cannot share one target hostname and path. +- Each unrelated root domain needs one source-to-target suffix rule. - Code that reads the database directly and bypasses WordPress filters may still expose canonical URLs. - Encodings other than the documented plain, JSON-escaped, and URL-encoded forms diff --git a/inc/domain-mapping/class-runtime-url-rewriter.php b/inc/domain-mapping/class-runtime-url-rewriter.php index ab77fcc7e..090946806 100644 --- a/inc/domain-mapping/class-runtime-url-rewriter.php +++ b/inc/domain-mapping/class-runtime-url-rewriter.php @@ -86,7 +86,7 @@ public function resolve_site($site, $domain, $path, $segments = null, $paths = [ $source_path = $this->translate_request_path($path, $this->active_mapping); remove_filter('pre_get_site_by_path', [$this, 'resolve_site'], 1); - $site = get_site_by_path($this->active_mapping['source']['authority'], $source_path); + $site = get_site_by_path($this->active_mapping['source']['host'], $source_path); add_filter('pre_get_site_by_path', [$this, 'resolve_site'], 1, 5); return $site; @@ -113,7 +113,7 @@ public function resolve_network($network, $domain, $path, $segments = null, $pat $source_path = $this->translate_request_path($path, $this->active_mapping); remove_filter('pre_get_network_by_path', [$this, 'resolve_network'], 1); - $network = get_network_by_path($this->active_mapping['source']['authority'], $source_path); + $network = get_network_by_path($this->active_mapping['source']['host'], $source_path); add_filter('pre_get_network_by_path', [$this, 'resolve_network'], 1, 5); return $network; @@ -239,47 +239,50 @@ public function rewrite_string($value) { } foreach ($this->mappings as $mapping) { - $target = $mapping['target']['authority'] . $mapping['target']['base_path']; - $absolute_target = $mapping['target']['scheme'] . '://' . $target; - $plain_source = preg_quote($mapping['source']['authority'], '#') + $subdomains = '(?P(?:[a-z0-9-]+\.)*)'; + $plain_source = $subdomains . preg_quote($mapping['source']['authority'], '#') . '(?-i:' . preg_quote($mapping['source']['base_path'], '#') . ')'; - $plain_boundary = '(?=$|[/?\#\s"\'<>)\]},;&])'; + $plain_boundary = '(?=$|[/?\#\s"\'<>)\]},;&])'; - $value = $this->replace_pattern( + $value = $this->replace_mapping_pattern( $value, '#https?://' . $plain_source . $plain_boundary . '#i', - $absolute_target + $mapping, + 'absolute' ); - $value = $this->replace_pattern( + $value = $this->replace_mapping_pattern( $value, '#(?)\]},;&])'; - $value = $this->replace_pattern( + $value = $this->replace_mapping_pattern( $value, '#https?:\\\\/\\\\/' . $escaped_source . $escaped_boundary . '#i', - $escaped_target + $mapping, + 'escaped' ); - $encoded_source = preg_quote(rawurlencode($mapping['source']['authority']), '#') + $encoded_source = $subdomains . preg_quote(rawurlencode($mapping['source']['authority']), '#') . $this->get_encoded_path_pattern($mapping['source']['base_path']); $encoded_boundary = '(?=$|%2F|%3F|%23|%20|%22|%27|%3C|%3E|%29|%5D|%7D|%2C|%3B|%26|[/?\#\s"\'<>)\]},;&])'; - $value = $this->replace_pattern( + $value = $this->replace_mapping_pattern( $value, '#https?%3A%2F%2F' . $encoded_source . $encoded_boundary . '#i', - rawurlencode($absolute_target) + $mapping, + 'encoded-absolute' ); - $value = $this->replace_pattern( + $value = $this->replace_mapping_pattern( $value, '#%2F%2F' . $encoded_source . $encoded_boundary . '#i', - rawurlencode('//' . $target) + $mapping, + 'encoded-protocol-relative' ); } @@ -287,22 +290,43 @@ public function rewrite_string($value) { } /** - * Replace a URL pattern while treating the replacement as a literal string. + * Replace a URL pattern while preserving any leading subdomain labels. * * @since 2.15.2 * - * @param string $value Subject string. - * @param string $pattern Regular expression. - * @param string $replacement Literal replacement. + * @param string $value Subject string. + * @param string $pattern Regular expression. + * @param array $mapping Normalized source and target mapping. + * @param string $format URL representation being replaced. * @return string */ - private function replace_pattern($value, $pattern, $replacement) { + private function replace_mapping_pattern($value, $pattern, $mapping, $format) { $result = preg_replace_callback( $pattern, - static function () use ($replacement) { - - return $replacement; + function ($matches) use ($mapping, $format) { + + $subdomains = isset($matches['wu_subdomains']) ? $matches['wu_subdomains'] : ''; + $source_authority = $subdomains . $mapping['source']['authority']; + + if ($this->authority_matches_any_target($source_authority)) { + return $matches[0]; + } + + $target = $subdomains . $mapping['target']['authority'] . $mapping['target']['base_path']; + + switch ($format) { + case 'protocol-relative': + return '//' . $target; + case 'escaped': + return str_replace('/', '\\/', $mapping['target']['scheme'] . '://' . $target); + case 'encoded-absolute': + return rawurlencode($mapping['target']['scheme'] . '://' . $target); + case 'encoded-protocol-relative': + return rawurlencode('//' . $target); + default: + return $mapping['target']['scheme'] . '://' . $target; + } }, $value ); @@ -310,6 +334,29 @@ static function () use ($replacement) { return is_string($result) ? $result : $value; } + /** + * Check whether an authority is already represented by any target rule. + * + * This prevents a broader parent source rule from rewriting the output of a + * more specific child rule when that child target sits inside the parent + * source suffix. + * + * @since 2.15.2 + * + * @param string $authority URL authority matched by a source rule. + * @return bool + */ + private function authority_matches_any_target($authority) { + + foreach ($this->mappings as $mapping) { + if (false !== $this->get_authority_prefix($authority, $mapping['target'])) { + return true; + } + } + + return false; + } + /** * Build a pattern with case-sensitive path text and flexible percent escapes. * @@ -392,6 +439,10 @@ public function allow_target_hosts($hosts) { $hosts[] = $mapping['target']['host']; } + if (! empty($this->active_mapping['target']['host'])) { + $hosts[] = $this->active_mapping['target']['host']; + } + return array_values(array_unique($hosts)); } @@ -414,11 +465,16 @@ public function get_mappings() { */ private function get_configured_mappings() { - $file_config = $this->get_file_configured_mappings(); - $inline_config = $this->decode_mapping_config( - $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP') - ); - $config = array_merge($file_config, $inline_config); + $config = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP'); + + if (is_string($config) && '' !== $config) { + $decoded = json_decode($config, true); + $config = is_array($decoded) ? $decoded : []; + } + + if (! is_array($config)) { + $config = []; + } if (empty($config)) { $target = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_TO'); @@ -480,59 +536,6 @@ static function ($left, $right) { return $mappings; } - /** - * Read mappings from a JSON file for installations with large domain sets. - * - * Inline mappings take precedence when the same source URL exists in both - * configuration sources. - * - * @since 2.15.2 - * @return array - */ - private function get_file_configured_mappings() { - - $file = $this->get_config_value('WP_ULTIMO_RUNTIME_URL_MAP_FILE'); - - if (! is_string($file) || '' === trim($file)) { - return []; - } - - $file = trim($file); - - if (! is_file($file) || ! is_readable($file)) { - return []; - } - - // The configured local file must be available before WordPress is fully loaded. - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $contents = file_get_contents($file); - - return false === $contents ? [] : $this->decode_mapping_config($contents); - } - - /** - * Decode a PHP array or JSON object containing source-to-target mappings. - * - * @since 2.15.2 - * - * @param mixed $config Mapping configuration. - * @return array - */ - private function decode_mapping_config($config) { - - if (is_array($config)) { - return $config; - } - - if (! is_string($config) || '' === trim($config)) { - return []; - } - - $decoded = json_decode($config, true); - - return is_array($decoded) ? $decoded : []; - } - /** * Read a constant first, then an environment variable of the same name. * @@ -607,6 +610,7 @@ private function normalize_url($url) { return [ 'scheme' => $scheme, 'host' => $host, + 'port' => isset($parts['port']) ? (int) $parts['port'] : null, 'authority' => $authority, 'base_path' => '/' === $base_path ? '' : rtrim($base_path, '/'), ]; @@ -629,21 +633,28 @@ private function find_request_mapping() { } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $parsed_path = parse_url($path, PHP_URL_PATH); - $path = is_string($parsed_path) ? $parsed_path : '/'; - $selected = null; - $best_length = -1; + $parsed_path = parse_url($path, PHP_URL_PATH); + $path = is_string($parsed_path) ? $parsed_path : '/'; + $selected = null; + $best_host_length = -1; + $best_path_length = -1; foreach ($this->mappings as $mapping) { - if (! $this->request_matches_mapping($host, $path, $mapping)) { + $prefix = $this->get_authority_prefix($host, $mapping['target']); + + if (false === $prefix || ! $this->request_path_matches_mapping($path, $mapping)) { continue; } - $target_length = strlen($mapping['target']['authority'] . $mapping['target']['base_path']); + $host_length = strlen($mapping['source']['host']); + $path_length = strlen($mapping['source']['base_path']); + $is_better = $host_length > $best_host_length + || ($host_length === $best_host_length && $path_length > $best_path_length); - if ($target_length > $best_length) { - $selected = $mapping; - $best_length = $target_length; + if ($is_better) { + $selected = $this->expand_mapping($mapping, $prefix); + $best_host_length = $host_length; + $best_path_length = $path_length; } } @@ -661,25 +672,26 @@ private function find_request_mapping() { */ private function request_matches_active_mapping($domain, $path) { + // wp_parse_url() may not be available in every sunrise integration. + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $domain_host = parse_url('http://' . $domain, PHP_URL_HOST); + return ! empty($this->active_mapping) - && $this->request_matches_mapping(strtolower($domain), $path, $this->active_mapping); + && is_string($domain_host) + && strtolower($domain_host) === $this->active_mapping['target']['host'] + && $this->request_path_matches_mapping($path, $this->active_mapping); } /** - * Check a host and path against a target mapping. + * Check a path against a target mapping. * * @since 2.15.2 * - * @param string $host Request host, optionally including a port. * @param string $path Request path. * @param array $mapping Normalized mapping. * @return bool */ - private function request_matches_mapping($host, $path, $mapping) { - - if ($host !== $mapping['target']['authority']) { - return false; - } + private function request_path_matches_mapping($path, $mapping) { $target_path = $mapping['target']['base_path']; @@ -688,6 +700,76 @@ private function request_matches_mapping($host, $path, $mapping) { || str_starts_with($path, $target_path . '/'); } + /** + * Expand a suffix rule with the subdomain labels from the current request. + * + * @since 2.15.2 + * + * @param array $mapping Normalized mapping. + * @param string $prefix Leading subdomain labels, including the final dot. + * @return array + */ + private function expand_mapping($mapping, $prefix) { + + foreach (['source', 'target'] as $side) { + $mapping[ $side ]['host'] = $prefix . $mapping[ $side ]['host']; + $mapping[ $side ]['authority'] = $prefix . $mapping[ $side ]['authority']; + } + + return $mapping; + } + + /** + * Get the leading labels when an authority matches a configured suffix. + * + * An empty string represents an exact root-domain match. False means the + * host or port does not belong to the configured suffix. + * + * @since 2.15.2 + * + * @param string $authority Request or matched URL authority. + * @param array $endpoint Normalized mapping endpoint. + * @return string|false + */ + private function get_authority_prefix($authority, $endpoint) { + + // wp_parse_url() may not be available in every sunrise integration. + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $parts = parse_url('http://' . $authority); + + if ( + ! is_array($parts) + || empty($parts['host']) + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['path']) + || isset($parts['query']) + || isset($parts['fragment']) + ) { + return false; + } + + $port = isset($parts['port']) ? (int) $parts['port'] : null; + + if ($port !== $endpoint['port']) { + return false; + } + + $host = strtolower($parts['host']); + + if ($host === $endpoint['host']) { + return ''; + } + + $suffix = '.' . $endpoint['host']; + + if (! str_ends_with($host, $suffix)) { + return false; + } + + return substr($host, 0, -strlen($endpoint['host'])); + } + /** * Translate a target request path to its canonical source path. * diff --git a/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php index 02e7af863..c7b653691 100644 --- a/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php @@ -130,8 +130,10 @@ public function test_rewrites_supported_url_forms() { 'https://EXAMPLE.ORG/site/' => 'https://staging.example.test/Preview/site/', 'http://example.org/site/' => 'https://staging.example.test/Preview/site/', '//example.org/site/' => '//staging.example.test/Preview/site/', - 'https:\/\/example.org\/asset.jpg' => 'https:\/\/staging.example.test\/Preview\/asset.jpg', - 'https%3a%2f%2fEXAMPLE.ORG%2fapi%2Fitem' => 'https%3A%2F%2Fstaging.example.test%2FPreview%2fapi%2Fitem', + 'https://customer-one.example.org/site/' => 'https://customer-one.staging.example.test/Preview/site/', + 'https://deep.site.example.org/site/' => 'https://deep.site.staging.example.test/Preview/site/', + 'https:\/\/customer-one.example.org\/asset.jpg' => 'https:\/\/customer-one.staging.example.test\/Preview\/asset.jpg', + 'https%3a%2f%2fcustomer-one.example.org%2fapi%2Fitem' => 'https%3A%2F%2Fcustomer-one.staging.example.test%2FPreview%2fapi%2Fitem', ]; foreach ($cases as $input => $expected) { @@ -147,6 +149,7 @@ public function test_does_not_rewrite_unsafe_partial_matches() { $unchanged = [ 'https://example.org:8443/site/', 'https://example.org.evil/site/', + 'https://customer.example.org.evil/site/', 'https://example.orgish/site/', 'admin@example.org', ]; @@ -227,59 +230,76 @@ public function test_rewrites_nested_values_and_upload_urls() { } /** - * Large mapping sets can be loaded from JSON with optional inline overrides. + * Root-domain rules preserve subdomains and allow specific child overrides. */ - public function test_loads_large_mapping_set_from_json_file() { + public function test_domain_suffix_rules_preserve_subdomains_and_child_precedence() { - $file = wp_tempnam('runtime-url-map.json'); - $this->assertIsString($file); + $mapping_filter = static function () { - $file_mappings = []; + return [ + 'https://example.com' => 'https://staging.example.com', + 'https://vip.example.com' => 'https://preview.example.com', + 'https://example.net/root' => 'https://staging.example.net/long-path', + 'https://vip.example.net' => 'https://vip.staging.example.net', + ]; + }; - for ($index = 1; $index <= 250; $index++) { - $file_mappings["https://customer-{$index}.example"] = "https://customer-{$index}.staging.example.test"; - } + add_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); - // Direct file and environment operations intentionally exercise pre-WordPress configuration. - // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv, WordPress.WP.AlternativeFunctions.unlink_unlink - $this->assertNotFalse(file_put_contents($file, wp_json_encode($file_mappings))); + $reflection = new \ReflectionClass(Runtime_URL_Rewriter::class); + $method = $reflection->getMethod('get_configured_mappings'); + $mappings = $method->invoke(self::$rewriter); - $previous_file = getenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE'); - $previous_inline = getenv('WP_ULTIMO_RUNTIME_URL_MAP'); + remove_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $property = $reflection->getProperty('mappings'); + $original = $property->getValue(self::$rewriter); - putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE=' . $file); - putenv('WP_ULTIMO_RUNTIME_URL_MAP={"https://customer-42.example":"https://override.staging.example.test"}'); + $property->setValue(self::$rewriter, $mappings); try { - $reflection = new \ReflectionClass(Runtime_URL_Rewriter::class); - $method = $reflection->getMethod('get_configured_mappings'); - $mappings = $method->invoke(self::$rewriter); - } finally { - false === $previous_file - ? putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE') - : putenv('WP_ULTIMO_RUNTIME_URL_MAP_FILE=' . $previous_file); - false === $previous_inline - ? putenv('WP_ULTIMO_RUNTIME_URL_MAP') - : putenv('WP_ULTIMO_RUNTIME_URL_MAP=' . $previous_inline); - unlink($file); - } - // phpcs:enable + $this->assertSame('https://staging.example.com', self::$rewriter->rewrite_string('https://example.com')); + $this->assertSame( + 'https://customer-one.staging.example.com/page', + self::$rewriter->rewrite_string('https://customer-one.example.com/page') + ); + $this->assertSame( + 'https://deep.site.staging.example.com/page', + self::$rewriter->rewrite_string('https://deep.site.example.com/page') + ); + $this->assertSame( + 'https://preview.example.com/page', + self::$rewriter->rewrite_string('https://vip.example.com/page') + ); + $this->assertSame( + 'https://customer.preview.example.com/page', + self::$rewriter->rewrite_string('https://customer.vip.example.com/page') + ); + $this->assertSame( + 'https://preview.example.com/page', + self::$rewriter->rewrite_string('https://preview.example.com/page') + ); + $this->assertSame( + 'https://customer-one.staging.example.com/page', + self::$rewriter->rewrite_string('https://customer-one.staging.example.com/page') + ); - $targets_by_source = []; + $_SERVER['HTTP_HOST'] = 'vip.staging.example.net'; + $_SERVER['REQUEST_URI'] = '/long-path'; - foreach ($mappings as $mapping) { - $targets_by_source[ $mapping['source']['authority'] ] = $mapping['target']['authority']; - } + try { + $find_request_mapping = $reflection->getMethod('find_request_mapping'); + $selected = $find_request_mapping->invoke(self::$rewriter); - $this->assertCount(250, $mappings); - $this->assertSame( - 'override.staging.example.test', - $targets_by_source['customer-42.example'] - ); - $this->assertSame( - 'customer-250.staging.example.test', - $targets_by_source['customer-250.example'] - ); + $this->assertSame('vip.example.net', $selected['source']['host']); + $this->assertSame('', $selected['source']['base_path']); + } finally { + $_SERVER['HTTP_HOST'] = 'staging.example.test'; + $_SERVER['REQUEST_URI'] = '/Preview/'; + } + } finally { + $property->setValue(self::$rewriter, $original); + } } /** @@ -298,6 +318,67 @@ public function test_resolves_canonical_site_and_network() { $this->assertSame('/', $network->path); } + /** + * A target subdomain and port resolve against the canonical site hostname. + */ + public function test_resolves_canonical_site_from_target_subdomain() { + + $source_domain = 'runtime-' . wp_rand(100000, 999999) . '.example.org'; + $target_domain = str_replace('.example.org', '.staging.example.test', $source_domain); + $blog_id = self::factory()->blog->create( + [ + 'domain' => $source_domain, + 'path' => '/', + ] + ); + + $this->assertNotWPError($blog_id); + + $mapping_filter = static function () { + + return ['https://example.org:8443' => 'https://staging.example.test:9443/Preview']; + }; + + add_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $reflection = new \ReflectionClass(Runtime_URL_Rewriter::class); + $method = $reflection->getMethod('get_configured_mappings'); + $mappings = $method->invoke(self::$rewriter); + + remove_filter('wu_runtime_url_rewriter_mappings', $mapping_filter); + + $previous_host = 'staging.example.test'; + $previous_uri = '/Preview/'; + $mappings_property = $reflection->getProperty('mappings'); + $active_property = $reflection->getProperty('active_mapping'); + $original_mappings = $mappings_property->getValue(self::$rewriter); + $original_active = $active_property->getValue(self::$rewriter); + + $mappings_property->setValue(self::$rewriter, $mappings); + $_SERVER['HTTP_HOST'] = $target_domain . ':9443'; + $_SERVER['REQUEST_URI'] = '/Preview/'; + + try { + $method = $reflection->getMethod('find_request_mapping'); + $mapping = $method->invoke(self::$rewriter); + + $this->assertSame($source_domain . ':8443', $mapping['source']['authority']); + $this->assertSame($target_domain . ':9443', $mapping['target']['authority']); + + $active_property->setValue(self::$rewriter, $mapping); + $site = self::$rewriter->resolve_site(null, $target_domain, '/Preview/'); + + $this->assertInstanceOf(\WP_Site::class, $site); + $this->assertSame($source_domain, $site->domain); + $this->assertContains($target_domain, self::$rewriter->allow_target_hosts([])); + } finally { + $mappings_property->setValue(self::$rewriter, $original_mappings); + $active_property->setValue(self::$rewriter, $original_active); + $_SERVER['HTTP_HOST'] = $previous_host; + $_SERVER['REQUEST_URI'] = $previous_uri; + } + } + /** * REST payloads and redirect hosts are rewritten consistently. */