PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
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 +130 -9 1.0.992.11.0 View file →
@@ -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
@@ -32,26 +102,71 @@
32 102 $data = (new \WP_oEmbed())->get_data($url, [
33 103 'discover' => false
34 104 ]);
35 105
36 - if (!$data || is_wp_error($data) || empty($data->provider_name)) {
106 + if (empty($data) || is_wp_error($data) || empty($data->provider_name)) {
37 107 return null;
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 - 'html' => Arr::get($data, 'html'),
50 - 'image' => Arr::get($data, 'thumbnail_url'),
126 + 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')),
127 + 'image' => $image,
51 128 ]);
52 129 }
53 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 +
54 169 public function getInfoFromRemoteUrl($url)
55 170 {
56 171 $url = untrailingslashit($url);
57 172
@@ -65,8 +180,14 @@
65 180 if ($cachedReponse) {
66 181 return $cachedReponse;
67 182 }
68 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 +
69 190 $remote_url_response = $this->getRemoteBody($url);
70 191 if (is_wp_error($remote_url_response) || empty($remote_url_response)) {
71 192 return $remote_url_response;
72 193 }
@@ -74,9 +195,9 @@
74 195 $html_head = $this->getDocumentHead($remote_url_response);
75 196
76 197 $title = $this->getTitle($html_head);
77 198 if (!$title) {
78 - 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));
79 200 }
80 201
81 202 $meta_elements = $this->getMetaWithContentElements($html_head);
82 203
@@ -88,9 +209,9 @@
88 209 'type' => 'meta_data',
89 210 'url' => $url
90 211 ]);
91 212
92 - 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
93 214
94 215 return $data;
95 216 }
96 217
@@ -112,9 +233,9 @@
112 233 * @param string $url The attempted URL.
113 234 * @since 5.9.0
114 235 *
115 236 */
116 - $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
117 238
118 239 $response = wp_safe_remote_get($url, $args);
119 240
120 241 if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) {
@@ -120,9 +241,9 @@
120 241 if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) {
121 242 // Not saving the error response to cache since the error might be temporary.
122 243 return new \WP_Error(
123 244 'no_response',
124 - __('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'),
125 246 array('status' => \WP_Http::NOT_FOUND)
126 247 );
127 248 }
128 249
@@ -130,9 +251,9 @@
130 251
131 252 if (empty($remote_body)) {
132 253 return new \WP_Error(
133 254 'no_content',
134 - __('Unable to retrieve body from response at this URL.'),
255 + __('Unable to retrieve body from response at this URL.', 'fluent-community'),
135 256 array('status' => \WP_Http::NOT_FOUND)
136 257 );
137 258 }
138 259