diff --git a/docs/RUNTIME-URL-REWRITING.md b/docs/RUNTIME-URL-REWRITING.md new file mode 100644 index 000000000..4848c30fa --- /dev/null +++ b/docs/RUNTIME-URL-REWRITING.md @@ -0,0 +1,118 @@ +# 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://example.com'); +define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.com'); +``` + +## Domain suffix mappings + +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_FROM', 'https://example.com'); +define('WP_ULTIMO_RUNTIME_URL_TO', 'https://staging.example.com'); +``` + +That single rule produces mappings such as: + +| 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` | + +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', + [ + 'https://example.com' => 'https://staging.example.com', + 'https://example.org' => 'https://staging.example.org', + ] +); +``` + +Environment variables cannot contain a PHP array, so use a JSON object: + +```text +WP_ULTIMO_RUNTIME_URL_MAP={"https://example.com":"https://staging.example.com","https://example.org":"https://staging.example.org"} +``` + +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 +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 + +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 + +- 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 + 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..090946806 --- /dev/null +++ b/inc/domain-mapping/class-runtime-url-rewriter.php @@ -0,0 +1,795 @@ +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']['host'], $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']['host'], $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) { + $subdomains = '(?P(?:[a-z0-9-]+\.)*)'; + $plain_source = $subdomains . preg_quote($mapping['source']['authority'], '#') + . '(?-i:' . preg_quote($mapping['source']['base_path'], '#') . ')'; + $plain_boundary = '(?=$|[/?\#\s"\'<>)\]},;&])'; + + $value = $this->replace_mapping_pattern( + $value, + '#https?://' . $plain_source . $plain_boundary . '#i', + $mapping, + 'absolute' + ); + $value = $this->replace_mapping_pattern( + $value, + '#(?)\]},;&])'; + + $value = $this->replace_mapping_pattern( + $value, + '#https?:\\\\/\\\\/' . $escaped_source . $escaped_boundary . '#i', + $mapping, + 'escaped' + ); + + $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_mapping_pattern( + $value, + '#https?%3A%2F%2F' . $encoded_source . $encoded_boundary . '#i', + $mapping, + 'encoded-absolute' + ); + $value = $this->replace_mapping_pattern( + $value, + '#%2F%2F' . $encoded_source . $encoded_boundary . '#i', + $mapping, + 'encoded-protocol-relative' + ); + } + + return $value; + } + + /** + * 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 array $mapping Normalized source and target mapping. + * @param string $format URL representation being replaced. + * @return string + */ + private function replace_mapping_pattern($value, $pattern, $mapping, $format) { + + $result = preg_replace_callback( + $pattern, + 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 + ); + + 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. + * + * @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']; + } + + if (! empty($this->active_mapping['target']['host'])) { + $hosts[] = $this->active_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, + 'port' => isset($parts['port']) ? (int) $parts['port'] : null, + '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_host_length = -1; + $best_path_length = -1; + + foreach ($this->mappings as $mapping) { + $prefix = $this->get_authority_prefix($host, $mapping['target']); + + if (false === $prefix || ! $this->request_path_matches_mapping($path, $mapping)) { + continue; + } + + $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 ($is_better) { + $selected = $this->expand_mapping($mapping, $prefix); + $best_host_length = $host_length; + $best_path_length = $path_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) { + + // 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) + && is_string($domain_host) + && strtolower($domain_host) === $this->active_mapping['target']['host'] + && $this->request_path_matches_mapping($path, $this->active_mapping); + } + + /** + * Check a path against a target mapping. + * + * @since 2.15.2 + * + * @param string $path Request path. + * @param array $mapping Normalized mapping. + * @return bool + */ + private function request_path_matches_mapping($path, $mapping) { + + $target_path = $mapping['target']['base_path']; + + return '' === $target_path + || $path === $target_path + || 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. + * + * @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..c7b653691 --- /dev/null +++ b/tests/WP_Ultimo/Domain_Mapping/Runtime_URL_Rewriter_Test.php @@ -0,0 +1,399 @@ + '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://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) { + $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://customer.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']); + } + + /** + * Root-domain rules preserve subdomains and allow specific child overrides. + */ + public function test_domain_suffix_rules_preserve_subdomains_and_child_precedence() { + + $mapping_filter = static function () { + + 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', + ]; + }; + + 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.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') + ); + + $_SERVER['HTTP_HOST'] = 'vip.staging.example.net'; + $_SERVER['REQUEST_URI'] = '/long-path'; + + try { + $find_request_mapping = $reflection->getMethod('find_request_mapping'); + $selected = $find_request_mapping->invoke(self::$rewriter); + + $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); + } + } + + /** + * 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); + } + + /** + * 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. + */ + 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']) + ); + } +}