PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/RemoteUrlParser.php +145 -28 1.0.922.10.01 View file →
@@ -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])) {