| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCommunity\App\Services; |
| 4 | 4 | |
| 5 | +use FluentCommunity\Framework\Support\Arr; | |
| 6 | + | |
| 5 | 7 | class RemoteUrlParser |
| 6 | 8 | { |
| 7 | 9 | |
| 8 | 10 | private static $instance; |
| @@ -24,50 +26,153 @@ | ||
| 24 | 26 | |
| 25 | 27 | return self::$instance->getInfoFromRemoteUrl($url); |
| 26 | 28 | } |
| 27 | 29 | |
| 28 | - public function getOembed($url) | |
| 30 | + public static function extractIframeThumbnail(&$html) | |
| 29 | 31 | { |
| 30 | - $supportedDomains = [ | |
| 31 | - 'youtube.com', | |
| 32 | - 'youtu.be', | |
| 33 | - 'vimeo.com', | |
| 34 | - 'dailymotion.com', | |
| 35 | - 'wordpress.tv', | |
| 36 | - 'screencast.com', | |
| 32 | + if (!is_string($html)) { | |
| 33 | + return ''; | |
| 34 | + } | |
| 35 | + $html = self::sanitizeOembedHtml($html); | |
| 36 | + if (!preg_match('/<iframe\s[^>]*\bsrc\s*=\s*([\'"])(.*?)\1/i', $html, $matches)) { | |
| 37 | + return ''; | |
| 38 | + } | |
| 39 | + | |
| 40 | + $src = sanitize_url(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); | |
| 41 | + if (!$src) { | |
| 42 | + return ''; | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (preg_match('#^https?://(?:[\w-]+\.)?youtube\.com/embed/([^?/]+)#i', $src, $ytMatch)) { | |
| 46 | + return self::bestYoutubeThumbnail($ytMatch[1]); | |
| 47 | + } | |
| 48 | + | |
| 49 | + $providers = [ | |
| 50 | + '#^https?://player\.vimeo\.com/video/([^?/]+).*$#i' => 'https://vumbnail.com/$1.jpg', | |
| 51 | + '#^https?://fast\.wistia\.net/embed/iframe/([^?/]+).*$#i' => 'https://fast.wistia.net/embed/medias/$1/swatch', | |
| 52 | + '#^https?://(?:www\.)?dailymotion\.com/(?:embed/video|player\.html\?video=)/?([^?/&]+).*$#i' => 'https://www.dailymotion.com/thumbnail/video/$1', | |
| 37 | 53 | ]; |
| 38 | 54 | |
| 55 | + foreach ($providers as $pattern => $template) { | |
| 56 | + $thumb = preg_replace($pattern, $template, $src, 1, $count); | |
| 57 | + if ($count) { | |
| 58 | + return $thumb; | |
| 59 | + } | |
| 60 | + } | |
| 39 | 61 | |
| 40 | - // Using regex to check if the $url is matched with any of the supported domains | |
| 41 | - $pattern = '#https?://(?:www\.)?(' . implode('|', $supportedDomains) . ')/#i'; | |
| 42 | - if (!preg_match($pattern, $url)) { | |
| 62 | + $parsed = self::parse($src); | |
| 63 | + return (!is_wp_error($parsed) && !empty($parsed['image'])) ? $parsed['image'] : ''; | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * Pick the best YouTube thumbnail for a feed preview. | |
| 68 | + * | |
| 69 | + * Runs on the feed-save path, so it stays cheap: a single HEAD probe for | |
| 70 | + * the HD WebP frame (~30-56% smaller than JPG, sharp 16:9), falling back | |
| 71 | + * to hqdefault.jpg — the one universally present rung (maxres/sd and even | |
| 72 | + * hqdefault.webp 404 for non-HD or legacy uploads). The result is stored | |
| 73 | + * on the feed and cached, so the probe is paid once per video. | |
| 74 | + */ | |
| 75 | + protected static function bestYoutubeThumbnail($videoId) | |
| 76 | + { | |
| 77 | + $videoId = sanitize_text_field($videoId); | |
| 78 | + if (!$videoId) { | |
| 79 | + return ''; | |
| 80 | + } | |
| 81 | + | |
| 82 | + $fallback = 'https://img.youtube.com/vi/' . $videoId . '/hqdefault.jpg'; | |
| 83 | + | |
| 84 | + $cacheKey = 'fcom_yt_thumb_' . md5($videoId); | |
| 85 | + $cached = get_transient($cacheKey); | |
| 86 | + if ($cached !== false) { | |
| 87 | + return $cached; | |
| 88 | + } | |
| 89 | + | |
| 90 | + $maxRes = 'https://i.ytimg.com/vi_webp/' . $videoId . '/maxresdefault.webp'; | |
| 91 | + $response = wp_remote_head($maxRes, ['timeout' => 1.5, 'redirection' => 0]); | |
| 92 | + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { | |
| 93 | + set_transient($cacheKey, $maxRes, WEEK_IN_SECONDS); | |
| 94 | + return $maxRes; | |
| 95 | + } | |
| 96 | + | |
| 97 | + return $fallback; | |
| 98 | + } | |
| 99 | + | |
| 100 | + public function getOembed($url) | |
| 101 | + { | |
| 102 | + $data = (new \WP_oEmbed())->get_data($url, [ | |
| 103 | + 'discover' => false | |
| 104 | + ]); | |
| 105 | + | |
| 106 | + if (empty($data) || is_wp_error($data) || empty($data->provider_name)) { | |
| 43 | 107 | return null; |
| 44 | 108 | } |
| 45 | 109 | |
| 46 | - $data = (new \WP_oEmbed())->get_data($url); | |
| 110 | + $data = (array)$data; | |
| 47 | 111 | |
| 48 | - if (!$data || is_wp_error($data) || empty($data->provider_name)) { | |
| 49 | - return null; | |
| 112 | + $provider = strtolower(Arr::get($data, 'provider_name')); | |
| 113 | + | |
| 114 | + $image = Arr::get($data, 'thumbnail_url'); | |
| 115 | + if ($provider === 'youtube') { | |
| 116 | + $image = self::bestYoutubeThumbnail(self::getYoutubeVideoId($url)) ?: $image; | |
| 50 | 117 | } |
| 51 | 118 | |
| 52 | 119 | return array_filter([ |
| 53 | - 'title' => $data->title, | |
| 54 | - 'author_name' => $data->author_name, | |
| 120 | + 'title' => Arr::get($data, 'title'), | |
| 121 | + 'author_name' => Arr::get($data, 'author_name'), | |
| 55 | 122 | 'type' => 'oembed', |
| 56 | - 'provider' => strtolower($data->provider_name), | |
| 57 | - 'content_type' => $data->type, | |
| 123 | + 'provider' => $provider, | |
| 124 | + 'content_type' => Arr::get($data, 'type'), | |
| 58 | 125 | 'url' => $url, |
| 59 | - 'html' => $data->html, | |
| 60 | - 'image' => $data->thumbnail_url, | |
| 126 | + 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')), | |
| 127 | + 'image' => $image, | |
| 61 | 128 | ]); |
| 62 | 129 | } |
| 63 | 130 | |
| 131 | + protected static function getYoutubeVideoId($url) | |
| 132 | + { | |
| 133 | + if (preg_match('#(?:youtu\.be/|youtube\.com/(?:embed/|v/|live/|shorts/|watch\?v=))([a-zA-Z0-9_-]+)#i', (string)$url, $match)) { | |
| 134 | + return $match[1]; | |
| 135 | + } | |
| 136 | + | |
| 137 | + return ''; | |
| 138 | + } | |
| 139 | + | |
| 140 | + public static function sanitizeOembedHtml($html) | |
| 141 | + { | |
| 142 | + if (empty($html)) { | |
| 143 | + return $html; | |
| 144 | + } | |
| 145 | + | |
| 146 | + static $allowed = null; | |
| 147 | + if ($allowed === null) { | |
| 148 | + $allowed = wp_kses_allowed_html('post'); | |
| 149 | + $allowed['iframe'] = [ | |
| 150 | + 'src' => true, | |
| 151 | + 'width' => true, | |
| 152 | + 'height' => true, | |
| 153 | + 'frameborder' => true, | |
| 154 | + 'allowfullscreen' => true, | |
| 155 | + 'title' => true, | |
| 156 | + 'loading' => true, | |
| 157 | + 'referrerpolicy' => true, | |
| 158 | + 'sandbox' => true, | |
| 159 | + ]; | |
| 160 | + } | |
| 161 | + | |
| 162 | + // Strip <script>/<style> incl. contents; wp_kses keeps their inner text otherwise. | |
| 163 | + $html = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', '', $html); | |
| 164 | + $html = preg_replace('#<(script|style)\b[^>]*/?>#i', '', $html); | |
| 165 | + | |
| 166 | + return wp_kses($html, $allowed, ['https']); | |
| 167 | + } | |
| 168 | + | |
| 64 | 169 | public function getInfoFromRemoteUrl($url) |
| 65 | 170 | { |
| 66 | 171 | $url = untrailingslashit($url); |
| 67 | 172 | |
| 68 | 173 | if (empty($url)) { |
| 69 | - return new \WP_Error('rest_invalid_url', __('Invalid URL'), array('status' => 404)); | |
| 174 | + return new \WP_Error('rest_invalid_url', __('Invalid URL', 'fluent-community'), array('status' => 404)); | |
| 70 | 175 | } |
| 71 | 176 | |
| 72 | 177 | $cacheKey = 'fcom_url_details_meta_' . md5($url); |
| 73 | 178 | $cachedReponse = wp_cache_get($cacheKey, 'fluent-community'); |
| @@ -75,20 +180,28 @@ | ||
| 75 | 180 | if ($cachedReponse) { |
| 76 | 181 | return $cachedReponse; |
| 77 | 182 | } |
| 78 | 183 | |
| 184 | + $preempted = apply_filters('fluent_community/preview_metadata_pre_fetch', null, $url); | |
| 185 | + if ($preempted && is_array($preempted)) { | |
| 186 | + wp_cache_set($cacheKey, $preempted, 'fluent-community', apply_filters('rest_url_details_cache_expiration', HOUR_IN_SECONDS)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound | |
| 187 | + return $preempted; | |
| 188 | + } | |
| 189 | + | |
| 79 | 190 | $remote_url_response = $this->getRemoteBody($url); |
| 80 | 191 | if (is_wp_error($remote_url_response) || empty($remote_url_response)) { |
| 81 | 192 | return $remote_url_response; |
| 82 | 193 | } |
| 194 | + | |
| 83 | 195 | $html_head = $this->getDocumentHead($remote_url_response); |
| 84 | 196 | |
| 85 | 197 | $title = $this->getTitle($html_head); |
| 86 | 198 | if (!$title) { |
| 87 | - return new \WP_Error('rest_invalid_url', __('Invalid URL'), array('status' => 404)); | |
| 199 | + return new \WP_Error('rest_invalid_url', __('Invalid URL', 'fluent-community'), array('status' => 404)); | |
| 88 | 200 | } |
| 89 | 201 | |
| 90 | 202 | $meta_elements = $this->getMetaWithContentElements($html_head); |
| 203 | + | |
| 91 | 204 | $data = array_filter([ |
| 92 | 205 | 'title' => $title, |
| 93 | 206 | 'image' => $this->getImage($meta_elements, $url), |
| 94 | 207 | 'description' => $this->getDescription($meta_elements), |
| @@ -96,9 +209,9 @@ | ||
| 96 | 209 | 'type' => 'meta_data', |
| 97 | 210 | 'url' => $url |
| 98 | 211 | ]); |
| 99 | 212 | |
| 100 | - wp_cache_set($cacheKey, $data, 'fluent-community', apply_filters('rest_url_details_cache_expiration', HOUR_IN_SECONDS)); | |
| 213 | + wp_cache_set($cacheKey, $data, 'fluent-community', apply_filters('rest_url_details_cache_expiration', HOUR_IN_SECONDS)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound | |
| 101 | 214 | |
| 102 | 215 | return $data; |
| 103 | 216 | } |
| 104 | 217 | |
| @@ -120,9 +233,9 @@ | ||
| 120 | 233 | * @param string $url The attempted URL. |
| 121 | 234 | * @since 5.9.0 |
| 122 | 235 | * |
| 123 | 236 | */ |
| 124 | - $args = apply_filters('rest_url_details_http_request_args', $args, $url); | |
| 237 | + $args = apply_filters('rest_url_details_http_request_args', $args, $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound | |
| 125 | 238 | |
| 126 | 239 | $response = wp_safe_remote_get($url, $args); |
| 127 | 240 | |
| 128 | 241 | if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) { |
| @@ -128,9 +241,9 @@ | ||
| 128 | 241 | if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) { |
| 129 | 242 | // Not saving the error response to cache since the error might be temporary. |
| 130 | 243 | return new \WP_Error( |
| 131 | 244 | 'no_response', |
| 132 | - __('URL not found. Response returned a non-200 status code for this URL.'), | |
| 245 | + __('URL not found. Response returned a non-200 status code for this URL.', 'fluent-community'), | |
| 133 | 246 | array('status' => \WP_Http::NOT_FOUND) |
| 134 | 247 | ); |
| 135 | 248 | } |
| 136 | 249 | |
| @@ -138,9 +251,9 @@ | ||
| 138 | 251 | |
| 139 | 252 | if (empty($remote_body)) { |
| 140 | 253 | return new \WP_Error( |
| 141 | 254 | 'no_content', |
| 142 | - __('Unable to retrieve body from response at this URL.'), | |
| 255 | + __('Unable to retrieve body from response at this URL.', 'fluent-community'), | |
| 143 | 256 | array('status' => \WP_Http::NOT_FOUND) |
| 144 | 257 | ); |
| 145 | 258 | } |
| 146 | 259 | |
| @@ -217,9 +330,13 @@ | ||
| 217 | 330 | $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/'; |
| 218 | 331 | $image = \WP_Http::make_absolute_url($image, $root_url); |
| 219 | 332 | } |
| 220 | 333 | |
| 221 | - return $image; | |
| 334 | + if (!$image) { | |
| 335 | + return $image; | |
| 336 | + } | |
| 337 | + | |
| 338 | + return sanitize_url(html_entity_decode($image, ENT_QUOTES | ENT_HTML5, 'UTF-8')); | |
| 222 | 339 | } |
| 223 | 340 | |
| 224 | 341 | private function getTitle($html) |
| 225 | 342 | { |
| @@ -225,9 +342,9 @@ | ||
| 225 | 342 | { |
| 226 | 343 | if (!$html) { |
| 227 | 344 | return ''; |
| 228 | 345 | } |
| 229 | - | |
| 346 | + | |
| 230 | 347 | $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is'; |
| 231 | 348 | preg_match($pattern, $html, $match_title); |
| 232 | 349 | |
| 233 | 350 | if (empty($match_title[1]) || !is_string($match_title[1])) { |