| @@ -26,8 +26,78 @@ | ||
| 26 | 26 | |
| 27 | 27 | return self::$instance->getInfoFromRemoteUrl($url); |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | + public static function extractIframeThumbnail(&$html) | |
| 31 | + { | |
| 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', | |
| 53 | + ]; | |
| 54 | + | |
| 55 | + foreach ($providers as $pattern => $template) { | |
| 56 | + $thumb = preg_replace($pattern, $template, $src, 1, $count); | |
| 57 | + if ($count) { | |
| 58 | + return $thumb; | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 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 | + | |
| 30 | 100 | public function getOembed($url) |
| 31 | 101 | { |
| 32 | 102 | $data = (new \WP_oEmbed())->get_data($url, [ |
| 33 | 103 | 'discover' => false |
| @@ -38,22 +108,38 @@ | ||
| 38 | 108 | } |
| 39 | 109 | |
| 40 | 110 | $data = (array)$data; |
| 41 | 111 | |
| 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; | |
| 117 | + } | |
| 118 | + | |
| 42 | 119 | return array_filter([ |
| 43 | 120 | 'title' => Arr::get($data, 'title'), |
| 44 | 121 | 'author_name' => Arr::get($data, 'author_name'), |
| 45 | 122 | 'type' => 'oembed', |
| 46 | - 'provider' => strtolower(Arr::get($data, 'provider_name')), | |
| 123 | + 'provider' => $provider, | |
| 47 | 124 | 'content_type' => Arr::get($data, 'type'), |
| 48 | 125 | 'url' => $url, |
| 49 | 126 | 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')), |
| 50 | - 'image' => Arr::get($data, 'thumbnail_url'), | |
| 127 | + 'image' => $image, | |
| 51 | 128 | ]); |
| 52 | 129 | } |
| 53 | 130 | |
| 54 | - private static function sanitizeOembedHtml($html) | |
| 131 | + protected static function getYoutubeVideoId($url) | |
| 55 | 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 | + { | |
| 56 | 142 | if (empty($html)) { |
| 57 | 143 | return $html; |
| 58 | 144 | } |
| 59 | 145 | |
| @@ -72,8 +158,12 @@ | ||
| 72 | 158 | 'sandbox' => true, |
| 73 | 159 | ]; |
| 74 | 160 | } |
| 75 | 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 | + | |
| 76 | 166 | return wp_kses($html, $allowed, ['https']); |
| 77 | 167 | } |
| 78 | 168 | |
| 79 | 169 | public function getInfoFromRemoteUrl($url) |
| @@ -88,8 +178,14 @@ | ||
| 88 | 178 | $cachedReponse = wp_cache_get($cacheKey, 'fluent-community'); |
| 89 | 179 | |
| 90 | 180 | if ($cachedReponse) { |
| 91 | 181 | return $cachedReponse; |
| 182 | + } | |
| 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; | |
| 92 | 188 | } |
| 93 | 189 | |
| 94 | 190 | $remote_url_response = $this->getRemoteBody($url); |
| 95 | 191 | if (is_wp_error($remote_url_response) || empty($remote_url_response)) { |