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 +133 -10 1.0.952.11.0 View file →
@@ -26,30 +26,147 @@
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 - $data = (new \WP_oEmbed())->get_data($url);
102 + $data = (new \WP_oEmbed())->get_data($url, [
103 + 'discover' => false
104 + ]);
33 105
34 - if (!$data || is_wp_error($data) || empty($data->provider_name)) {
106 + if (empty($data) || is_wp_error($data) || empty($data->provider_name)) {
35 107 return null;
36 108 }
37 109
38 110 $data = (array)$data;
39 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 +
40 119 return array_filter([
41 120 'title' => Arr::get($data, 'title'),
42 121 'author_name' => Arr::get($data, 'author_name'),
43 122 'type' => 'oembed',
44 - 'provider' => strtolower(Arr::get($data, 'provider_name')),
123 + 'provider' => $provider,
45 124 'content_type' => Arr::get($data, 'type'),
46 125 'url' => $url,
47 - 'html' => Arr::get($data, 'html'),
48 - 'image' => Arr::get($data, 'thumbnail_url'),
126 + 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')),
127 + 'image' => $image,
49 128 ]);
50 129 }
51 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 +
52 169 public function getInfoFromRemoteUrl($url)
53 170 {
54 171 $url = untrailingslashit($url);
55 172
@@ -63,8 +180,14 @@
63 180 if ($cachedReponse) {
64 181 return $cachedReponse;
65 182 }
66 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 +
67 190 $remote_url_response = $this->getRemoteBody($url);
68 191 if (is_wp_error($remote_url_response) || empty($remote_url_response)) {
69 192 return $remote_url_response;
70 193 }
@@ -72,9 +195,9 @@
72 195 $html_head = $this->getDocumentHead($remote_url_response);
73 196
74 197 $title = $this->getTitle($html_head);
75 198 if (!$title) {
76 - 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));
77 200 }
78 201
79 202 $meta_elements = $this->getMetaWithContentElements($html_head);
80 203
@@ -86,9 +209,9 @@
86 209 'type' => 'meta_data',
87 210 'url' => $url
88 211 ]);
89 212
90 - 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
91 214
92 215 return $data;
93 216 }
94 217
@@ -110,9 +233,9 @@
110 233 * @param string $url The attempted URL.
111 234 * @since 5.9.0
112 235 *
113 236 */
114 - $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
115 238
116 239 $response = wp_safe_remote_get($url, $args);
117 240
118 241 if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) {
@@ -118,9 +241,9 @@
118 241 if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) {
119 242 // Not saving the error response to cache since the error might be temporary.
120 243 return new \WP_Error(
121 244 'no_response',
122 - __('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'),
123 246 array('status' => \WP_Http::NOT_FOUND)
124 247 );
125 248 }
126 249
@@ -128,9 +251,9 @@
128 251
129 252 if (empty($remote_body)) {
130 253 return new \WP_Error(
131 254 'no_content',
132 - __('Unable to retrieve body from response at this URL.'),
255 + __('Unable to retrieve body from response at this URL.', 'fluent-community'),
133 256 array('status' => \WP_Http::NOT_FOUND)
134 257 );
135 258 }
136 259