attributes that hold URLs. * * @var array */ const TAG_ATTRS = array( 'a' => array( 'href' ), 'img' => array( 'src' ), 'iframe' => array( 'src' ), 'video' => array( 'src', 'poster' ), 'audio' => array( 'src' ), 'source' => array( 'src' ), 'embed' => array( 'src' ), ); /** * Base URL for resolving relative links. * * @var string */ private $base = ''; /** * Sets the base URL used to resolve relative URLs. * * @param string $base Absolute URL. * @return $this */ public function with_base( $base ) { $this->base = $base ? $base : home_url( '/' ); return $this; } /** * Extracts from any post content (HTML with optional block comments). * * @param string $content Content. * @param string $field Field label stored with each instance. * @return array[] */ public function from_content( $content, $field ) { $content = (string) $content; if ( '' === trim( $content ) ) { return array(); } $found = $this->from_html( $content, $field ); if ( function_exists( 'has_blocks' ) && has_blocks( $content ) ) { $found = array_merge( $found, $this->from_blocks( parse_blocks( $content ), $field ) ); } if ( false !== strpos( $content, '[' ) ) { $found = array_merge( $found, $this->from_shortcodes( $content, $field ) ); } // Bare URLs on their own line become oEmbeds. if ( preg_match_all( '#^\s*(https?://[^\s<>"\']+)\s*$#im', $content, $m ) ) { foreach ( $m[1] as $url ) { $found[] = $this->item( $url, $field, 'embed', '' ); } } return $this->finish( $found, $field ); } /** * Extracts from an HTML fragment. * * @param string $html HTML. * @param string $field Field label. * @return array[] */ public function from_html( $html, $field ) { $found = array(); $html = (string) $html; if ( false === strpos( $html, '<' ) ) { return $found; } if ( ! preg_match_all( '#<(a|img|iframe|video|audio|source|embed)\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { return $found; } foreach ( $matches[0] as $index => $match ) { $tag_html = $match[0]; $offset = $match[1]; $tag = strtolower( $matches[1][ $index ][0] ); $processor = new WP_HTML_Tag_Processor( $tag_html ); if ( ! $processor->next_tag() ) { continue; } foreach ( self::TAG_ATTRS[ $tag ] as $attr ) { $value = $processor->get_attribute( $attr ); if ( ! is_string( $value ) || '' === trim( $value ) ) { continue; } $anchor = ''; if ( 'a' === $tag ) { $end = stripos( $html, '', $offset ); if ( false !== $end ) { $inner = substr( $html, $offset + strlen( $tag_html ), $end - $offset - strlen( $tag_html ) ); $anchor = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $inner ) ) ); if ( '' === $anchor && preg_match( '#get_attribute( 'alt' ); $anchor = is_string( $alt ) ? $alt : ''; } $element = 'a' === $tag ? 'a' : ( 'img' === $tag ? 'img' : $tag ); $found[] = $this->item( $value, $field, $element, $anchor ); } } return $found; } /** * Extracts URL-like attributes from parsed blocks (embeds, navigation links, buttons). * * @param array $blocks Parsed blocks. * @param string $field Field label. * @return array[] */ public function from_blocks( array $blocks, $field ) { $found = array(); foreach ( $blocks as $block ) { if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) { $this->walk_attrs( $block['attrs'], $field, $found ); } if ( ! empty( $block['innerBlocks'] ) ) { $found = array_merge( $found, $this->from_blocks( $block['innerBlocks'], $field ) ); } } return $found; } /** * Walks block attributes for URL values. * * @param array $attrs Attributes. * @param string $field Field label. * @param array $found Collected items (by reference). * @return void */ private function walk_attrs( array $attrs, $field, array &$found ) { foreach ( $attrs as $key => $value ) { if ( is_array( $value ) ) { $this->walk_attrs( $value, $field, $found ); continue; } if ( ! is_string( $value ) ) { continue; } $key_name = strtolower( (string) $key ); if ( preg_match( '/^(url|href|src|link|linkurl|mediaurl|poster)$/', $key_name ) && preg_match( '#^(https?:)?//#i', $value ) ) { $found[] = $this->item( $value, $field, 'block', '' ); } } } /** * Reads URLs from shortcode attributes without running any shortcode. * * Page builders such as Divi, WPBakery, Avada and Beaver Builder's shortcode mode keep * button, image and video URLs in attributes like url="", button_url="", src="" or * link="url:...|title:...". The content is only parsed as text: no shortcode callback * runs, so there are no side effects, no extra queries and no outgoing requests. * * @param string $content Content. * @param string $field Field label. * @return array[] */ public function from_shortcodes( $content, $field ) { $found = array(); $content = (string) $content; if ( ! preg_match_all( '/(? $value ) { if ( ! is_string( $key ) || ! is_string( $value ) ) { continue; } $item = $this->shortcode_attr_item( strtolower( $key ), trim( $value ), $field ); if ( null !== $item ) { $found[] = $item; } } } return $found; } /** * One URL item from a shortcode attribute, or null. * * @param string $key Attribute name (lower case). * @param string $value Attribute value. * @param string $field Field label. * @return array|null */ private function shortcode_attr_item( $key, $value, $field ) { // WPBakery link fields: url:https%3A%2F%2Fexample.com|title:Text|target:_blank. if ( 0 === strpos( $value, 'url:' ) && preg_match( '/^url:([^|]+)/', $value, $m ) ) { $url = rawurldecode( $m[1] ); return preg_match( '#^https?://#i', $url ) ? $this->item( $url, $field, 'shortcode', '' ) : null; } /** * Filters the shortcode attribute names that hold a URL. * * @param string $pattern Regular expression matched against the lower-case attribute name. */ $pattern = (string) apply_filters( 'linktender_shortcode_url_attributes', '/(^|_)(url|href|src|link|image|img|poster|mp4|webm|ogg|mp3|logo|background_image|bg_image)$/' ); if ( ! preg_match( $pattern, $key ) || ! preg_match( '#^(https?:)?//[^\s"\'<>]+$#i', $value ) ) { return null; } $is_image = preg_match( '/(image|img|logo|poster)$/', $key ) || preg_match( '#\.(png|jpe?g|gif|webp|avif|svg)(\?.*)?$#i', $value ); $element = $is_image ? 'img' : 'shortcode'; return $this->item( $value, $field, $element, '' ); } /** * Extracts from Elementor's JSON page data. * * @param string|array $data JSON string or decoded array. * @param string $field Field label. * @return array[] */ public function from_elementor( $data, $field ) { if ( is_string( $data ) ) { $data = json_decode( $data, true ); } if ( ! is_array( $data ) ) { return array(); } $found = array(); $this->walk_elementor( $data, $field, $found, '' ); return $this->finish( $found, $field ); } /** * Recursive Elementor walker. * * @param array $node Node. * @param string $field Field label. * @param array $found Collected items (by reference). * @param string $parent_key Parent key. * @return void */ private function walk_elementor( array $node, $field, array &$found, $parent_key ) { foreach ( $node as $key => $value ) { if ( is_array( $value ) ) { $this->walk_elementor( $value, $field, $found, (string) $key ); continue; } if ( ! is_string( $value ) || '' === $value ) { continue; } if ( 'url' === $key && preg_match( '#^(https?:)?//#i', $value ) ) { $element = ( false !== strpos( $parent_key, 'image' ) ) ? 'img' : 'a'; $found[] = $this->item( $value, $field, $element, '' ); } elseif ( false !== strpos( $value, '<' ) ) { $found = array_merge( $found, $this->from_html( $value, $field ) ); } } } /** * Extracts from a generic stored value (custom fields, widget settings). * * @param mixed $value Value (string or nested array). * @param string $field Field label. * @return array[] */ public function from_value( $value, $field ) { $found = array(); $this->walk_value( $value, $field, $found, 0 ); return $this->finish( $found, $field ); } /** * Recursive walker for generic values. * * @param mixed $value Value. * @param string $field Field label. * @param array $found Collected (by reference). * @param int $depth Recursion depth. * @return void */ private function walk_value( $value, $field, array &$found, $depth ) { if ( $depth > 8 ) { return; } if ( is_array( $value ) ) { foreach ( $value as $child ) { $this->walk_value( $child, $field, $found, $depth + 1 ); } return; } if ( ! is_string( $value ) || '' === $value ) { return; } $trimmed = trim( $value ); if ( preg_match( '#^https?://[^\s<>"]+$#i', $trimmed ) ) { $found[] = $this->item( $trimmed, $field, 'field', '' ); return; } if ( false !== strpos( $value, '<' ) ) { $found = array_merge( $found, $this->from_html( $value, $field ) ); if ( has_blocks( $value ) ) { $found = array_merge( $found, $this->from_blocks( parse_blocks( $value ), $field ) ); } } } /** * Builds one raw item. * * @param string $raw URL as written. * @param string $field Field label. * @param string $element Element type. * @param string $anchor Anchor text. * @return array */ private function item( $raw, $field, $element, $anchor ) { return array( 'raw_url' => $raw, 'field' => $field, 'element' => $element, 'anchor' => (string) $anchor, ); } /** * Resolves, filters and de-duplicates raw items. * * @param array[] $found Raw items. * @param string $field Field label. * @return array[] */ public function finish( array $found, $field ) { $settings = LinkTender_Settings::all(); $out = array(); $seen = array(); $home = wp_parse_url( home_url(), PHP_URL_HOST ); foreach ( $found as $item ) { $url = $this->resolve( $item['raw_url'] ); if ( null === $url ) { continue; } if ( 'img' === $item['element'] && empty( $settings['check_images'] ) ) { continue; } if ( in_array( $item['element'], array( 'iframe', 'video', 'audio', 'source', 'embed' ), true ) && empty( $settings['check_media'] ) ) { continue; } $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( empty( $settings['check_internal'] ) && $host === $home ) { continue; } if ( LinkTender_Links::is_admin_or_login( $url ) || LinkTender_Settings::is_url_excluded( $url ) ) { continue; } // Block and embed hits repeat what the HTML pass already found. $key = $url . '|' . $item['raw_url']; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $item['url'] = $url; $out[] = $item; } /** * Filters URLs extracted from one field. * * @param array[] $out Items with url, raw_url, field, element, anchor. * @param string $field Field label. */ return apply_filters( 'linktender_extracted_urls', $out, $field ); } /** * Makes a URL absolute; returns null for URLs that cannot be checked. * * @param string $raw URL as written. * @return string|null */ public function resolve( $raw ) { $url = LinkTender_Links::normalize( $raw ); if ( '' === $url ) { return null; } if ( preg_match( '#^([a-z][a-z0-9+.-]*):#i', $url, $m ) ) { $scheme = strtolower( $m[1] ); if ( 'http' !== $scheme && 'https' !== $scheme ) { // Skip non-web schemes such as mail, phone, script and data links. return null; } } else { if ( false !== strpos( $url, '{' ) || false !== strpos( $url, '[' ) ) { return null; // Template placeholders and shortcodes. } $url = WP_Http::make_absolute_url( $url, $this->base ? $this->base : home_url( '/' ) ); } if ( ! wp_http_validate_url( $url ) && ! preg_match( '#^https?://[^/\s]+#i', $url ) ) { return null; } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( empty( $host ) || strlen( $url ) > 2000 ) { return null; } return $url; } }