Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,21 @@
*/
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
class CautiousTextBlockMarkupUrlProcessor extends BlockMarkupUrlProcessor {
/** @var array<string, string> */
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<string, string> $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;
}

/**
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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<string, string> $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();
}

/**
Expand Down Expand Up @@ -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 =
'(?<path_slash>' . $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 . '
|
(?=(?<path_slash>' . $separator_escape . '/))
)';
}

return '~
(?<![A-Za-z0-9._%+\\/@-])
(?:
(?:
(?<scheme>(?i:' . preg_quote($source_scheme, '~') . '))
(?<scheme_colon>' . $separator_escape . ':)
|
(?<!:)
)
(?<url_slash>(?<url_slash_escape>' . $separator_escape . ')/)
\k<url_slash_escape>/
(?:[^\s<>@/\\\\]+@)?
)?
(?<base>
(?<authority>(?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;
}
}
Loading
Loading