From 6fd889cce37016b7d0251d5ae1d6329d8e5f70d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 14 Aug 2026 02:10:29 +0200 Subject: [PATCH] [PHP] Prepare cautious URL mappings once --- ...utious-text-block-markup-url-processor.php | 19 +- ...n-text-with-mixed-unknown-escape-rules.php | 198 +-------------- ...lass-cautious-url-base-rewrite-mapping.php | 226 ++++++++++++++++++ .../class-structured-data-url-rewriter.php | 16 +- .../src/lib/url-rewrite/load.php | 1 + ...rInTextWithMixedUnknownEscapeRulesTest.php | 36 ++- 6 files changed, 288 insertions(+), 208 deletions(-) create mode 100644 packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-rewrite-mapping.php diff --git a/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php b/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php index ebacf0c4..164f9f61 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php @@ -35,18 +35,21 @@ */ // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound class CautiousTextBlockMarkupUrlProcessor extends BlockMarkupUrlProcessor { - /** @var array */ - private array $url_mapping; + private CautiousURLBaseRewriteMapping $prepared_url_mapping; /** - * @param string $html Block markup to process. - * @param string|null $base_url_string Base URL for exact URL parsing. - * @param array $url_mapping Source URL base => target URL. + * @param string $html Block markup to process. + * @param string|null $base_url_string Base URL for exact URL parsing. + * @param CautiousURLBaseRewriteMapping $prepared_url_mapping Prepared URL mapping. */ - public function __construct($html, ?string $base_url_string, array $url_mapping) + public function __construct( + $html, + ?string $base_url_string, + CautiousURLBaseRewriteMapping $prepared_url_mapping + ) { parent::__construct($html, $base_url_string); - $this->url_mapping = $url_mapping; + $this->prepared_url_mapping = $prepared_url_mapping; } /** @@ -90,7 +93,7 @@ private function replace_url_bases_in_current_token(): bool $raw_token = substr($html, $token_span->start, $token_span->length); $processor = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( $raw_token, - $this->url_mapping + $this->prepared_url_mapping ); while ($processor->next_url()) { $processor->replace_url_base(); diff --git a/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php index 168f92e2..fb886087 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php @@ -88,11 +88,12 @@ * Example usage: * * ```php + * $mapping = new CautiousURLBaseRewriteMapping([ + * 'https://source.example' => 'https://destination.example', + * ]); * $processor = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( * '[vc_video link="https:\\/\\/source.example\\/media\\/video.mp4"]', - * [ - * 'https://source.example' => 'https://destination.example', - * ] + * $mapping * ); * * while ($processor->next_url()) { @@ -148,38 +149,15 @@ class CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules { /** * Creates a processor for one opaque text value. * - * A source may include an initial path containing only bytes from `!` - * (0x21) through `~` (0x7E). A target must be an HTTP(S) URL with a - * supported domain, optional port, and optional restricted path: - * - * ``` - * [ - * 'https://source.example/media' => 'https://destination.example/assets', - * ] - * ``` - * - * Invalid mappings are skipped as a whole. They cannot produce a partial - * domain replacement. - * - * @param array $url_mapping Source URL base => target URL. + * @param CautiousURLBaseRewriteMapping $url_mapping Prepared URL mapping. */ - public function __construct(string $text, array $url_mapping) + public function __construct( + string $text, + CautiousURLBaseRewriteMapping $url_mapping + ) { $this->text = $text; - - foreach ($url_mapping as $source_url => $target_url) { - $mapping = $this->create_url_mapping($source_url, $target_url); - if ($mapping !== null) { - $this->url_mappings[] = $mapping; - } - } - - usort( - $this->url_mappings, - static function (array $first, array $second): int { - return strlen($second['source_base']) <=> strlen($first['source_base']); - } - ); + $this->url_mappings = $url_mapping->get_entries(); } /** @@ -340,160 +318,4 @@ private function find_next_url_base(): ?array return $next_match; } - - /** - * Build a candidate pattern adapted from URLInTextProcessor's URL finder. - * - * The pattern recognizes this mapping's absolute, protocol-relative, and - * scheme-less forms. It captures the first slash before the authority and - * the first slash in or after the configured source base. The first - * available capture supplies the spelling for a target path. - */ - private function create_url_candidate_pattern( - string $source_scheme, - string $source_authority, - string $source_path, - bool $requires_path_slash - ): string - { - $separator_escape = '\\\\{0,8}'; - $source_path_pattern = ''; - if ($source_path !== '') { - $source_path_pattern = - '(?' . $separator_escape . '/)' - . str_replace( - '/', - $separator_escape . '/', - preg_quote(substr($source_path, 1), '~') - ); - } - $candidate_boundary_pattern = '(?= - $ - | ' . $separator_escape . '/ - | [/?# \t\r\n,!;)\]}>"\'] - )'; - if ($requires_path_slash && $source_path === '') { - $candidate_boundary_pattern = '(?(url_slash) - ' . $candidate_boundary_pattern . ' - | - (?=(?' . $separator_escape . '/)) - )'; - } - - return '~ - (?(?i:' . preg_quote($source_scheme, '~') . ')) - (?' . $separator_escape . ':) - | - (?(?' . $separator_escape . ')/) - \k/ - (?:[^\s<>@/\\\\]+@)? - )? - (? - (?(?i:' . preg_quote($source_authority, '~') . ')) - ' . $source_path_pattern . ' - ) - ' . $candidate_boundary_pattern . ' - ~x'; - } - - /** - * @return array{ - * source_authority: string, - * source_path: string, - * source_base: string, - * target_domain: string, - * target_scheme: string, - * target_path: string, - * target_port: int|null, - * pattern: string - * }|null - */ - private function create_url_mapping(string $source_url, string $target_url): ?array - { - $source = $this->get_supported_url_parts($source_url, true); - $target = $this->get_supported_url_parts($target_url, false); - if ($source === null || $target === null) { - return null; - } - - // A source URL ending at its authority uses / as the URL separator, - // not as an initial path to remove. Leave its original spelling alone. - $source_path = $source['path'] === '/' ? '' : $source['path']; - - return [ - 'source_authority' => $source['authority'], - 'source_path' => $source_path, - 'source_base' => $source['authority'] . $source_path, - 'target_domain' => $target['host'], - 'target_scheme' => $target['scheme'], - 'target_path' => $target['path'], - 'target_port' => $target['port'], - 'pattern' => $this->create_url_candidate_pattern( - $source['scheme'], - $source['authority'], - $source_path, - $target['path'] !== '' - ), - ]; - } - - /** - * @return array{scheme: string, host: string, authority: string, path: string, port: int|null}|null - */ - private function get_supported_url_parts(string $url, bool $is_source_url): ?array - { - $parts = parse_url($url); - if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { - return null; - } - - foreach (['user', 'pass', 'query', 'fragment'] as $unsupported_part) { - if (array_key_exists($unsupported_part, $parts)) { - return null; - } - } - - $scheme = strtolower( (string) $parts['scheme'] ); - $host = (string) $parts['host']; - $path = isset($parts['path']) ? (string) $parts['path'] : ''; - $has_unsupported_target_path = - !$is_source_url - && $path !== '' - && preg_match('#^/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$#', $path) !== 1; - if (( $scheme !== 'http' && $scheme !== 'https' ) - || ( !$is_source_url && $has_unsupported_target_path ) - || !( $this->is_alphanumeric_dot_hyphen_domain_name($host) || ( $is_source_url && $this->is_ip_address($host) ) ) - || !$this->contains_only_exclamation_mark_through_tilde_bytes($path)) { - return null; - } - - return [ - 'scheme' => $scheme, - 'host' => $host, - 'authority' => $host . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ), - 'path' => $path, - 'port' => isset($parts['port']) ? (int) $parts['port'] : null, - ]; - } - - private function is_ip_address(string $host): bool - { - return filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false; - } - - private function is_alphanumeric_dot_hyphen_domain_name(string $domain): bool - { - return filter_var($domain, FILTER_VALIDATE_IP) === false - && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/', $domain) === 1; - } - - private function contains_only_exclamation_mark_through_tilde_bytes(string $path): bool - { - return $path === '' || preg_match('/^[\x21-\x7E]+$/', $path) === 1; - } } diff --git a/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-rewrite-mapping.php b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-rewrite-mapping.php new file mode 100644 index 00000000..cdb25bc8 --- /dev/null +++ b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-rewrite-mapping.php @@ -0,0 +1,226 @@ + + */ + private array $entries = []; + + /** + * Prepares source URL base => target URL pairs. + * + * Invalid pairs are skipped as a whole. They cannot produce a partial + * domain replacement. + * + * @param array $url_mapping Source URL base => target URL. + */ + public function __construct(array $url_mapping) + { + foreach ($url_mapping as $source_url => $target_url) { + $entry = $this->create_entry($source_url, $target_url); + if ($entry !== null) { + $this->entries[] = $entry; + } + } + + usort( + $this->entries, + static function (array $first, array $second): int { + return strlen($second['source_base']) <=> strlen($first['source_base']); + } + ); + } + + /** + * Returns the prepared mappings in longest-source-first order. + * + * @return array + */ + public function get_entries(): array + { + return $this->entries; + } + + /** + * @return array{ + * source_authority: string, + * source_path: string, + * source_base: string, + * target_domain: string, + * target_scheme: string, + * target_path: string, + * target_port: int|null, + * pattern: string + * }|null + */ + private function create_entry(string $source_url, string $target_url): ?array + { + $source = $this->get_supported_url_parts($source_url, true); + $target = $this->get_supported_url_parts($target_url, false); + if ($source === null || $target === null) { + return null; + } + + // A source URL ending at its authority uses / as the URL separator, + // not as an initial path to remove. Leave its original spelling alone. + $source_path = $source['path'] === '/' ? '' : $source['path']; + + return [ + 'source_authority' => $source['authority'], + 'source_path' => $source_path, + 'source_base' => $source['authority'] . $source_path, + 'target_domain' => $target['host'], + 'target_scheme' => $target['scheme'], + 'target_path' => $target['path'], + 'target_port' => $target['port'], + 'pattern' => $this->create_url_candidate_pattern( + $source['scheme'], + $source['authority'], + $source_path, + $target['path'] !== '' + ), + ]; + } + + /** + * Build a candidate pattern adapted from URLInTextProcessor's URL finder. + * + * The pattern recognizes one mapping's absolute, protocol-relative, and + * scheme-less forms. It captures the first slash before the authority and + * the first slash in or after the configured source base. The first + * available capture supplies the spelling for a target path. + */ + private function create_url_candidate_pattern( + string $source_scheme, + string $source_authority, + string $source_path, + bool $requires_path_slash + ): string + { + $separator_escape = '\\\\{0,8}'; + $source_path_pattern = ''; + if ($source_path !== '') { + $source_path_pattern = + '(?' . $separator_escape . '/)' + . str_replace( + '/', + $separator_escape . '/', + preg_quote(substr($source_path, 1), '~') + ); + } + $candidate_boundary_pattern = '(?= + $ + | ' . $separator_escape . '/ + | [/?# \t\r\n,!;)\]}>"\'] + )'; + if ($requires_path_slash && $source_path === '') { + $candidate_boundary_pattern = '(?(url_slash) + ' . $candidate_boundary_pattern . ' + | + (?=(?' . $separator_escape . '/)) + )'; + } + + return '~ + (?(?i:' . preg_quote($source_scheme, '~') . ')) + (?' . $separator_escape . ':) + | + (?(?' . $separator_escape . ')/) + \k/ + (?:[^\s<>@/\\\\]+@)? + )? + (? + (?(?i:' . preg_quote($source_authority, '~') . ')) + ' . $source_path_pattern . ' + ) + ' . $candidate_boundary_pattern . ' + ~x'; + } + + /** + * @return array{scheme: string, host: string, authority: string, path: string, port: int|null}|null + */ + private function get_supported_url_parts(string $url, bool $is_source_url): ?array + { + $parts = parse_url($url); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + foreach (['user', 'pass', 'query', 'fragment'] as $unsupported_part) { + if (array_key_exists($unsupported_part, $parts)) { + return null; + } + } + + $scheme = strtolower( (string) $parts['scheme'] ); + $host = (string) $parts['host']; + $path = isset($parts['path']) ? (string) $parts['path'] : ''; + $has_unsupported_target_path = + !$is_source_url + && $path !== '' + && preg_match('#^/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$#', $path) !== 1; + if (( $scheme !== 'http' && $scheme !== 'https' ) + || ( !$is_source_url && $has_unsupported_target_path ) + || !( $this->is_alphanumeric_dot_hyphen_domain_name($host) || ( $is_source_url && $this->is_ip_address($host) ) ) + || !$this->contains_only_exclamation_mark_through_tilde_bytes($path)) { + return null; + } + + return [ + 'scheme' => $scheme, + 'host' => $host, + 'authority' => $host . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ), + 'path' => $path, + 'port' => isset($parts['port']) ? (int) $parts['port'] : null, + ]; + } + + private function is_ip_address(string $host): bool + { + return filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false; + } + + private function is_alphanumeric_dot_hyphen_domain_name(string $domain): bool + { + return filter_var($domain, FILTER_VALIDATE_IP) === false + && preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/', $domain) === 1; + } + + private function contains_only_exclamation_mark_through_tilde_bytes(string $path): bool + { + return $path === '' || preg_match('/^[\x21-\x7E]+$/', $path) === 1; + } +} diff --git a/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php b/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php index 7a93dc93..8072a157 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php @@ -36,14 +36,8 @@ class StructuredDataUrlRewriter /** @var string[] Source domains extracted from url_mapping keys, for quick-reject checks. */ private array $source_domains; - /** - * Exact configured URL spellings used for byte-level replacement in block - * markup text tokens. The parsed mapping below serves the structured URL - * processors; it cannot preserve the caller's lexical source base. - * - * @var array - */ - private array $url_mapping; + /** Prepared URL mapping shared by cautious text processors. */ + private CautiousURLBaseRewriteMapping $cautious_url_base_rewrite_mapping; /** * Pre-parsed url_mapping: each entry is @@ -91,7 +85,7 @@ class StructuredDataUrlRewriter */ public function __construct(array $url_mapping) { - $this->url_mapping = $url_mapping; + $this->cautious_url_base_rewrite_mapping = new CautiousURLBaseRewriteMapping($url_mapping); // Extract unique source domains for the quick-reject check. $domains = []; @@ -444,7 +438,7 @@ private function rewrite_urls( string $content, string $content_type ): string { switch ( $content_type ) { case self::BLOCK_MARKUP: - $p = new CautiousTextBlockMarkupUrlProcessor( $content, $base_url, $this->url_mapping ); + $p = new CautiousTextBlockMarkupUrlProcessor( $content, $base_url, $this->cautious_url_base_rewrite_mapping ); while ( $p->next_token() ) { $token_type = $p->get_token_type() ?? ''; while ( $p->next_url_in_current_token() ) { @@ -496,7 +490,7 @@ private function rewrite_urls( string $content, string $content_type ): string { $p = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( $content, - $this->url_mapping + $this->cautious_url_base_rewrite_mapping ); while ( $p->next_url() ) { $p->replace_url_base(); diff --git a/packages/reprint-client/src/lib/url-rewrite/load.php b/packages/reprint-client/src/lib/url-rewrite/load.php index 94a01bf7..c3d5dff3 100644 --- a/packages/reprint-client/src/lib/url-rewrite/load.php +++ b/packages/reprint-client/src/lib/url-rewrite/load.php @@ -15,6 +15,7 @@ require_once __DIR__ . '/class-base64-value-scanner.php'; require_once __DIR__ . '/class-fast-insert-scanner.php'; require_once __DIR__ . '/class-sqlite-prepared-insert-builder.php'; +require_once __DIR__ . '/class-cautious-url-base-rewrite-mapping.php'; require_once __DIR__ . '/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php'; // Extends the toolkit block processor and uses the cautious text processor. diff --git a/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php b/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php index 8dd9f3a1..6699037e 100644 --- a/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php +++ b/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php @@ -35,6 +35,37 @@ public function testLeavesUnsupportedTextUnchanged( $this->assertSame($expected, $this->rewrite($input, $mapping)); } + public function testPreparedMappingCanBeReusedForDifferentTextValues(): void + { + $mapping = new CautiousURLBaseRewriteMapping([ + 'https://source.example' => 'https://destination.example', + ]); + + $first = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( + 'https://source.example/first.png', + $mapping + ); + while ($first->next_url()) { + $first->replace_url_base(); + } + $this->assertSame( + 'https://destination.example/first.png', + $first->get_updated_text() + ); + + $second = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( + 'https://source.example/second.png', + $mapping + ); + while ($second->next_url()) { + $second->replace_url_base(); + } + $this->assertSame( + 'https://destination.example/second.png', + $second->get_updated_text() + ); + } + /** * @return array}> */ @@ -618,7 +649,10 @@ public static function unsupported_cases(): array */ private function rewrite(string $text, array $mapping): string { - $processor = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules($text, $mapping); + $processor = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( + $text, + new CautiousURLBaseRewriteMapping($mapping) + ); while ($processor->next_url()) { $processor->replace_url_base();