diff --git a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php index 5c0452afe..fbfd62c10 100644 --- a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php +++ b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php @@ -351,6 +351,11 @@ public function set_url( $raw_url, $parsed_url ) { if ( null === $this->raw_url ) { return false; } + if ( $raw_url === $this->raw_url ) { + // A relative URL may resolve against a new base without changing its source spelling. + $this->parsed_url = $parsed_url; + return true; + } $this->raw_url = $raw_url; $this->parsed_url = $parsed_url; switch ( parent::get_token_type() ) { @@ -384,14 +389,15 @@ public function set_url( $raw_url, $parsed_url ) { /** * Rewrites the components of the currently matched URL from ones - * provided in $from_url to ones specified in $to_url. + * provided in $base_url to ones specified in $to_url. * - * It preserves the relative nature of the matched URL. + * Structured values retain unmatched decoded URL bytes and their relative + * nature. Text nodes retain their complete-value behavior. * * @TODO: Should this method live in this class? It's specific to the import process * and the URL rewriting logic and has knowledge about the quirks of detecting - * relative URLs in text nodes. On the other hand, the detection is performed - * by this WPURL_In_Text_Processor class so maybe the two do go hand in hand? + * relative URLs in text nodes. On the other hand, URLInTextProcessor performs + * that detection, so maybe the two do go hand in hand? */ public function replace_base_url( $to_url, $base_url = null ) { $base_url = $base_url ?? $this->base_url_object; @@ -421,10 +427,14 @@ public function replace_base_url( $to_url, $base_url = null ) { if ( false === $result ) { return false; } + if ( '#text' === parent::get_token_type() ) { + return $this->set_url( (string) $result, $result->new_url ); + } + if ( null === $result->new_source_preserving_raw_url ) { + return false; + } - $this->set_url( $result . '', $result->new_url ); - - return true; + return $this->set_url( $result->new_source_preserving_raw_url, $result->new_url ); } /** diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index e54f88864..0b32b77c4 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -888,23 +888,36 @@ public function set_token_value( string $new_value ): bool { // Only URL and string tokens are currently supported. switch ( $this->token_type ) { case self::TOKEN_URL: - $this->lexical_updates[] = array( - 'start' => $this->token_value_starts_at, - 'length' => $this->token_value_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_value_starts_at; + $update_length = $this->token_value_length; + break; case self::TOKEN_STRING: - $this->lexical_updates[] = array( - 'start' => $this->token_starts_at, - 'length' => $this->token_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_starts_at; + $update_length = $this->token_length; + break; default: _doing_it_wrong( __METHOD__, 'set_token_value() only supports URL and string tokens. Got token type: ' . $this->token_type, '1.0.0' ); return false; } + + $escaped_value = $this->escape_url_value( $new_value ); + $last_update_index = count( $this->lexical_updates ) - 1; + // A later write to the current token supersedes its pending update. + if ( + 0 <= $last_update_index && + $update_start === $this->lexical_updates[ $last_update_index ]['start'] && + $update_length === $this->lexical_updates[ $last_update_index ]['length'] + ) { + $this->lexical_updates[ $last_update_index ]['text'] = $escaped_value; + return true; + } + + $this->lexical_updates[] = array( + 'start' => $update_start, + 'length' => $update_length, + 'text' => $escaped_value, + ); + return true; } /** diff --git a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php index 22193d9fe..f8b3d65e9 100644 --- a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php +++ b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php @@ -196,7 +196,7 @@ public static function provider_test_set_url_examples() { 'In the "url" block attribute of a navigation-link block' => array( '', 'https://w.org', - '', + '', ), 'In a text node' => array( 'Have you seen https://wordpress.org yet?', @@ -305,6 +305,76 @@ public static function provider_test_next_url_replace_base_url() { ); } + /** + * @dataProvider provider_replace_base_url_in_structured_values + */ + public function test_replace_base_url_in_structured_values( $markup, $expected ) { + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertSame( $expected, $p->get_updated_html() ); + } + + public static function provider_replace_base_url_in_structured_values() { + return array( + 'HTML attribute' => array( + '', + '', + ), + 'CSS URL' => array( + '
', + '
', + ), + 'block attribute' => array( + '', + '', + ), + ); + } + + public function test_replace_base_url_updates_parsed_url_without_a_source_replacement() { + $markup = ''; + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://old.example/media' ) ); + $this->assertSame( 'https://old.example/media/file', $p->get_parsed_url()->toString() ); + $this->assertSame( $markup, $p->get_updated_html() ); + } + + public function test_replace_base_url_rewrites_later_css_urls_after_rendering_an_earlier_update() { + $markup = '
'; + $p = new BlockMarkupUrlProcessor( $markup, 'http://very-long-old.example/very/long/base' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $p->get_updated_html(); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $this->assertSame( + '
', + $p->get_updated_html() + ); + } + + public function test_set_url_replaces_a_pending_css_base_update() { + $p = new BlockMarkupUrlProcessor( + '
', + 'http://old.example/media' + ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertTrue( $p->set_url( 'https://other.example/file', WPURL::parse( 'https://other.example/file' ) ) ); + $this->assertSame( + '
', + $p->get_updated_html() + ); + } + /** * @dataProvider provider_test_css_url_detection */ diff --git a/components/DataLiberation/Tests/CSSProcessorTest.php b/components/DataLiberation/Tests/CSSProcessorTest.php index 965f9e5a3..30b79c1bf 100644 --- a/components/DataLiberation/Tests/CSSProcessorTest.php +++ b/components/DataLiberation/Tests/CSSProcessorTest.php @@ -1615,7 +1615,7 @@ public function test_set_token_value_handles_emoji(): void { } /** - * Tests that multiple URL values can be updated in the same CSS. + * Tests that multiple URL values can be updated and the last update to a token wins. */ public function test_set_token_value_multiple_urls(): void { $css = 'background: url(old1.jpg); border-image: url(old2.png);'; @@ -1628,6 +1628,7 @@ public function test_set_token_value_multiple_urls(): void { if ( 1 === $url_count ) { $processor->set_token_value( 'new1.jpg' ); } elseif ( 2 === $url_count ) { + $processor->set_token_value( 'discarded.png' ); $processor->set_token_value( 'new2.png' ); } } diff --git a/components/DataLiberation/Tests/RewriteUrlsTest.php b/components/DataLiberation/Tests/RewriteUrlsTest.php index 17aca6d57..8babab2c0 100644 --- a/components/DataLiberation/Tests/RewriteUrlsTest.php +++ b/components/DataLiberation/Tests/RewriteUrlsTest.php @@ -5,6 +5,36 @@ use function WordPress\DataLiberation\URL\wp_rewrite_urls; class RewriteUrlsTest extends TestCase { + public function test_structured_urls_use_the_selected_mapping_base() { + $this->assertSame( + '', + wp_rewrite_urls( + array( + 'block_markup' => '', + 'base_url' => 'http://old.example/root/first/', + 'url-mapping' => array( + 'http://old.example/root/first/' => 'https://first.example/first/', + 'http://old.example/root/second/' => 'https://second.example/assets/', + ), + ) + ) + ); + } + + public function test_text_urls_keep_the_configured_base_url_semantics() { + $this->assertSame( + '

https://new.example/assets/sub/file

', + wp_rewrite_urls( + array( + 'block_markup' => '

http://old.example/root/sub/file

', + 'base_url' => 'http://old.example/root/', + 'url-mapping' => array( + 'http://old.example/root/sub/' => 'https://new.example/assets/', + ), + ) + ) + ); + } /** * diff --git a/components/DataLiberation/Tests/WPURLTest.php b/components/DataLiberation/Tests/WPURLTest.php index 337e79491..3de4eb9a6 100644 --- a/components/DataLiberation/Tests/WPURLTest.php +++ b/components/DataLiberation/Tests/WPURLTest.php @@ -87,4 +87,176 @@ public static function provider_test_replace_base_url_trailing_slash() { ), ); } + + /** + * @dataProvider provider_replace_base_url_source_preserving_raw_url + */ + public function test_replace_base_url_returns_source_preserving_raw_url( + $raw_url, + $old_base_url, + $new_base_url, + $expected_raw_url, + $include_raw_url = true + ) { + $url = WPURL::parse( $raw_url, $old_base_url ); + $options = array( + 'old_base_url' => $old_base_url, + 'new_base_url' => $new_base_url, + 'is_relative' => ! WPURL::can_parse( $raw_url ), + ); + if ( $include_raw_url ) { + $options['raw_url'] = $raw_url; + } + $result = WPURL::replace_base_url( + $url, + $options + ); + + $this->assertNotFalse( $result ); + $this->assertSame( $expected_raw_url, $result->new_source_preserving_raw_url ); + if ( null === $expected_raw_url ) { + return; + } + + $this->assertSame( + WPURL::parse( $expected_raw_url, $new_base_url )->toString(), + $result->new_url->toString() + ); + } + + public static function provider_replace_base_url_source_preserving_raw_url() { + return array( + 'absolute' => array( + 'http://old.example/media/file?x=1#section', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file?x=1#section', + ), + 'protocol-relative' => array( + '//old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '//new.example/assets/file', + ), + 'root-relative' => array( + '/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'percent-encoded source segment' => array( + '/m%65dia/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'path-relative' => array( + 'file', + 'http://old.example/', + 'https://new.example/assets/', + '/assets/file', + ), + 'query-only' => array( + '?x=1', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/?x=1', + ), + 'empty relative URL' => array( + '', + 'http://old.example/media/', + 'https://new.example/assets/', + '/assets/', + ), + 'exact absolute base' => array( + 'http://old.example/media', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets', + ), + 'explicit default port' => array( + 'http://old.example:80/media/file', + 'http://old.example/media', + 'https://old.example/assets', + 'https://old.example/assets/file', + ), + 'non-default source port' => array( + 'http://old.example:81/media/file', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file', + ), + 'no-op keeps same input' => array( + 'http://OLD.example/media/%7euser', + 'http://old.example/media', + 'http://old.example/media', + 'http://OLD.example/media/%7euser', + ), + 'file URL' => array( + 'file://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'explicit scheme without slashes' => array( + 'http:media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'parent-directory-relative path' => array( + '../file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'backslashes in mapped components' => array( + 'http://old.example\\media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'encoded slash at the mapped boundary' => array( + '/media%2Fa', + 'http://old.example/media', + 'https://new.example/', + null, + ), + 'parent segment hides a different lexical base' => array( + 'http://old.example/x/../media/file', + 'http://old.example/media', + 'http://old.example/foo/media', + null, + ), + 'encoded slash and dot hide different lexical segments' => array( + '/a%2Fb/./file', + 'http://old.example/a/b', + 'https://new.example/assets', + null, + ), + 'raw URL not supplied' => array( + 'http://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + false, + ), + ); + } + + public function test_replace_base_url_source_preserving_raw_url_follows_the_semantic_result() { + $raw_url = 'http://user@old.example/media/file'; + $result = WPURL::replace_base_url( + WPURL::parse( $raw_url ), + array( + 'old_base_url' => 'http://old.example/media', + 'new_base_url' => 'https://new.example/assets', + 'raw_url' => $raw_url, + 'is_relative' => false, + ) + ); + + $this->assertNotFalse( $result ); + $this->assertSame( (string) $result, $result->new_source_preserving_raw_url ); + } } diff --git a/components/DataLiberation/URL/class-convertedurl.php b/components/DataLiberation/URL/class-convertedurl.php index 1a12dade6..abcfe5ceb 100644 --- a/components/DataLiberation/URL/class-convertedurl.php +++ b/components/DataLiberation/URL/class-convertedurl.php @@ -22,6 +22,14 @@ class ConvertedUrl { /** @var string|null */ public $new_raw_relative_url; + /** + * Updated decoded URL with only its base spelling changed, or null when the + * source spelling could not be aligned safely. + * + * @var string|null + */ + public $new_source_preserving_raw_url = null; + /** @var bool */ public $was_relative = false; diff --git a/components/DataLiberation/URL/class-wpurl.php b/components/DataLiberation/URL/class-wpurl.php index b12013617..61cb1d3b4 100644 --- a/components/DataLiberation/URL/class-wpurl.php +++ b/components/DataLiberation/URL/class-wpurl.php @@ -230,11 +230,177 @@ public static function replace_base_url( $url, $options ) { $converted_url->was_relative = true; $converted_url->new_raw_relative_url = $relative_url; } + + $raw_url_base_replacements = self::create_raw_url_base_replacements( + $options['raw_url'], + $url, + $updated_url, + $old_base_url, + $new_base_url + ); + if ( null !== $raw_url_base_replacements ) { + $source_preserving_raw_url = $options['raw_url']; + foreach ( array_reverse( $raw_url_base_replacements ) as $replacement ) { + $source_preserving_raw_url = substr_replace( + $source_preserving_raw_url, + $replacement['replacement'], + $replacement['start'], + $replacement['length'] + ); + } + + $source_preserving_url = self::parse( $source_preserving_raw_url, $new_base_url->toString() ); + $expected_url = self::parse( (string) $converted_url, $new_base_url->toString() ); + if ( + false !== $source_preserving_url && + false !== $expected_url && + $source_preserving_url->toString() === $expected_url->toString() + ) { + $converted_url->new_source_preserving_raw_url = $source_preserving_raw_url; + $converted_url->new_url = $source_preserving_url; + } + } } return $converted_url; } + /** + * Creates decoded-URL base replacements using slash-delimited path segments. + * + * Only spellings whose base-component boundaries can be located from literal delimiters are accepted. + * Ambiguous spellings return null. The caller reparses the edited string and accepts + * it only when it matches the normal semantic conversion. + * + * @return array|null + */ + private static function create_raw_url_base_replacements( $raw_url, $url, $updated_url, $old_base_url, $new_base_url ) { + $source_url = self::parse( $raw_url, $old_base_url->toString() ); + if ( + false === $source_url || + $source_url->toString() !== $url->toString() || + ! is_child_url_of( $source_url, $old_base_url ) + ) { + return null; + } + + $is_absolute = 1 === preg_match( '/\A([A-Za-z][A-Za-z0-9+.\-]*):\/\//', $raw_url, $absolute_match ); + $is_protocol_relative = 0 === strpos( $raw_url, '//' ); + $is_path_relative = ! $is_absolute && ! $is_protocol_relative && 0 !== strpos( $raw_url, '/' ); + if ( + ( $is_absolute && 'file' === strtolower( $absolute_match[1] ) ) || + ( ! $is_absolute && 1 === preg_match( '/\A[A-Za-z][A-Za-z0-9+.\-]*:/', $raw_url ) ) || + ( $is_path_relative && ( 0 === strpos( $raw_url, './' ) || 0 === strpos( $raw_url, '../' ) ) ) + ) { + return null; + } + + $path_end = strcspn( $raw_url, '?#' ); + $path_start = 0; + $replacements = array(); + if ( $is_absolute || $is_protocol_relative ) { + $scheme_end = $is_absolute ? strpos( $raw_url, ':' ) : null; + $authority_start = $is_absolute ? $scheme_end + 3 : 2; + $path_start = $authority_start + strcspn( $raw_url, '/?#', $authority_start ); + if ( $authority_start === $path_start ) { + return null; + } + + $authority = substr( $raw_url, $authority_start, $path_start - $authority_start ); + $userinfo_at = strrpos( $authority, '@' ); + $host_start = $authority_start + ( false === $userinfo_at ? 0 : $userinfo_at + 1 ); + $raw_host = substr( $raw_url, $host_start, $path_start - $host_start ); + if ( $is_absolute && $url->protocol !== $updated_url->protocol ) { + $replacements[] = array( + 'start' => 0, + 'length' => $scheme_end, + 'replacement' => rtrim( $updated_url->protocol, ':' ), + ); + } + if ( + $url->host !== $updated_url->host || + ( + $url->protocol !== $updated_url->protocol && + 1 === preg_match( '/:\d+$/', $raw_host ) + ) + ) { + $replacements[] = array( + 'start' => $host_start, + 'length' => $path_start - $host_start, + 'replacement' => $updated_url->host, + ); + } + } + + if ( $old_base_url->pathname !== $new_base_url->pathname ) { + $raw_path = substr( $raw_url, $path_start, $path_end - $path_start ); + + /* + * Propose a boundary after the same number of slash-delimited segments as the + * parsed base. Parsing that prefix validates its normalized path. Split before + * decoding so "%2F" cannot become a delimiter. + */ + $source_segment_count = substr_count( rtrim( $old_base_url->pathname, '/' ), '/' ); + if ( $is_path_relative ) { + $source_segment_count = ( + '' === $raw_path || + 0 === $source_segment_count || + '/' === substr( $old_base_url->pathname, -1 ) + ) ? 0 : 1; + } + + $raw_path_segments = explode( '/', $raw_path ); + $segments_to_use = $source_segment_count + ( 0 === strpos( $raw_path, '/' ) ? 1 : 0 ); + $source_length = strlen( implode( '/', array_slice( $raw_path_segments, 0, $segments_to_use ) ) ); + + $source_prefix_url = self::parse( + substr( $raw_url, 0, $path_start + $source_length ), + $old_base_url->toString() + ); + if ( + false === $source_prefix_url || + $source_prefix_url->protocol !== $old_base_url->protocol || + $source_prefix_url->hostname !== $old_base_url->hostname || + array_map( 'rawurldecode', explode( '/', rtrim( $source_prefix_url->pathname, '/' ) ) ) !== + array_map( 'rawurldecode', explode( '/', rtrim( $old_base_url->pathname, '/' ) ) ) + ) { + return null; + } + + $unmatched_path = substr( $raw_path, $source_length ); + $target_path = '/' === $new_base_url->pathname ? '' : rtrim( $new_base_url->pathname, '/' ); + if ( + '' !== $target_path && + ( + ( '' !== $unmatched_path && '/' !== $unmatched_path[0] ) || + ( '' === $unmatched_path && $path_end < strlen( $raw_url ) ) || + ( $is_path_relative && '' === $raw_path && '/' === substr( $old_base_url->pathname, -1 ) ) + ) + ) { + $target_path .= '/'; + } elseif ( + '' === $unmatched_path && + '' === $target_path && + ( ! $is_path_relative || '' === $raw_path ) + ) { + $target_path = '/'; + } + $replacements[] = array( + 'start' => $path_start, + 'length' => $source_length, + 'replacement' => $target_path, + ); + } + + foreach ( $replacements as $replacement ) { + if ( false !== strpos( substr( $raw_url, $replacement['start'], $replacement['length'] ), '\\' ) ) { + return null; + } + } + + return $replacements; + } + /** * Prepends a protocol to any matched URL without the double slash. * diff --git a/components/DataLiberation/URL/functions.php b/components/DataLiberation/URL/functions.php index fe931ce3d..3081bc169 100644 --- a/components/DataLiberation/URL/functions.php +++ b/components/DataLiberation/URL/functions.php @@ -8,8 +8,11 @@ require_once __DIR__ . '/class-urlrewritecache.php'; /** - * Migrate URLs in post content. See WPRewriteUrlsTests for - * specific examples. TODO: A better description. + * Rewrites mapped URLs in post content. + * + * Structured values change only the mapped base in the decoded URL. The existing + * HTML, CSS, or block serializer still applies its normal escaping to the complete + * value. Text-node URLs retain their existing complete-value behavior. * * Example: * @@ -68,48 +71,28 @@ function wp_rewrite_urls( $options ) { $p = new BlockMarkupUrlProcessor( $options['block_markup'], $options['base_url'] ); while ( $p->next_url() ) { - $token_type = $p->get_token_type(); - $raw_url = $p->get_raw_url(); - $cache_key = $mapping_cache_key . "\0" . $token_type . "\0" . $raw_url; - - $cached = $rewrite_cache->get( $cache_key ); - if ( null !== $cached ) { - if ( false !== $cached ) { - $p->set_url( $cached['raw_url'], $cached['parsed_url'] ); + $cache_key = $mapping_cache_key . "\0" . $p->get_parsed_url()->toString(); + + $mapping_index = $rewrite_cache->get( $cache_key ); + if ( null === $mapping_index ) { + $mapping_index = false; + foreach ( $url_mapping as $index => $mapping ) { + if ( is_child_url_of( $p->get_parsed_url(), $mapping['from_url'] ) ) { + $mapping_index = $index; + break; + } } - continue; + $rewrite_cache->set( $cache_key, $mapping_index ); } - $parsed_url = $p->get_parsed_url(); - $converted = false; - foreach ( $url_mapping as $mapping ) { - if ( is_child_url_of( $parsed_url, $mapping['from_url'] ) ) { - $converted = WPURL::replace_base_url( - $parsed_url, - array( - 'old_base_url' => $base_url_object, - 'new_base_url' => $mapping['to_url'], - 'raw_url' => $raw_url, - 'is_relative' => ( - '#text' !== $token_type && - ! WPURL::can_parse( $raw_url ) - ), - ) - ); - break; - } - } - - $cache_value = false; - if ( false !== $converted ) { - $cache_value = array( - 'raw_url' => (string) $converted, - 'parsed_url' => $converted->new_url, + if ( false !== $mapping_index ) { + $p->replace_base_url( + $url_mapping[ $mapping_index ]['to_url'], + '#text' === $p->get_token_type() + ? $base_url_object + : $url_mapping[ $mapping_index ]['from_url'] ); - $p->set_url( $cache_value['raw_url'], $cache_value['parsed_url'] ); } - - $rewrite_cache->set( $cache_key, $cache_value ); } return $p->get_updated_html();