PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.5
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.5
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 1.1.0 All 77 releases
fluent-community / app / Services / RemoteUrlParser.php

RemoteUrlParser.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.7.5, at app/Services/RemoteUrlParser.php

443 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\Framework\Support\Arr;
6
7 class RemoteUrlParser
8 {
9
10 private static $instance;
11
12 /*
13 * Create a method to call the getInfoFromRemoteUrl static method as magic method
14 */
15 public static function parse($url)
16 {
17 if (!self::$instance) {
18 self::$instance = new self();
19 }
20
21 $oEmbed = self::$instance->getOembed($url);
22
23 if ($oEmbed) {
24 return $oEmbed;
25 }
26
27 return self::$instance->getInfoFromRemoteUrl($url);
28 }
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
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)) {
107 return null;
108 }
109
110 $data = (array)$data;
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
119 return array_filter([
120 'title' => Arr::get($data, 'title'),
121 'author_name' => Arr::get($data, 'author_name'),
122 'type' => 'oembed',
123 'provider' => $provider,
124 'content_type' => Arr::get($data, 'type'),
125 'url' => $url,
126 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')),
127 'image' => $image,
128 ]);
129 }
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 return wp_kses($html, $allowed, ['https']);
163 }
164
165 public function getInfoFromRemoteUrl($url)
166 {
167 $url = untrailingslashit($url);
168
169 if (empty($url)) {
170 return new \WP_Error('rest_invalid_url', __('Invalid URL', 'fluent-community'), array('status' => 404));
171 }
172
173 $cacheKey = 'fcom_url_details_meta_' . md5($url);
174 $cachedReponse = wp_cache_get($cacheKey, 'fluent-community');
175
176 if ($cachedReponse) {
177 return $cachedReponse;
178 }
179
180 $preempted = apply_filters('fluent_community/preview_metadata_pre_fetch', null, $url);
181 if ($preempted && is_array($preempted)) {
182 wp_cache_set($cacheKey, $preempted, 'fluent-community', apply_filters('rest_url_details_cache_expiration', HOUR_IN_SECONDS)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
183 return $preempted;
184 }
185
186 $remote_url_response = $this->getRemoteBody($url);
187 if (is_wp_error($remote_url_response) || empty($remote_url_response)) {
188 return $remote_url_response;
189 }
190
191 $html_head = $this->getDocumentHead($remote_url_response);
192
193 $title = $this->getTitle($html_head);
194 if (!$title) {
195 return new \WP_Error('rest_invalid_url', __('Invalid URL', 'fluent-community'), array('status' => 404));
196 }
197
198 $meta_elements = $this->getMetaWithContentElements($html_head);
199
200 $data = array_filter([
201 'title' => $title,
202 'image' => $this->getImage($meta_elements, $url),
203 'description' => $this->getDescription($meta_elements),
204 'icon' => $this->getIcon($html_head, $url),
205 'type' => 'meta_data',
206 'url' => $url
207 ]);
208
209 wp_cache_set($cacheKey, $data, 'fluent-community', apply_filters('rest_url_details_cache_expiration', HOUR_IN_SECONDS)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
210
211 return $data;
212 }
213
214 private function getRemoteBody($url)
215 {
216 $modified_user_agent = 'WP-URLDetails/' . get_bloginfo('version') . ' (+' . get_bloginfo('url') . ')';
217
218 $args = array(
219 'limit_response_size' => 300 * KB_IN_BYTES,
220 'user-agent' => $modified_user_agent,
221 );
222
223 /**
224 * Filters the HTTP request args for URL data retrieval.
225 *
226 * Can be used to adjust response size limit and other WP_Http::request() args.
227 *
228 * @param array $args Arguments used for the HTTP request.
229 * @param string $url The attempted URL.
230 * @since 5.9.0
231 *
232 */
233 $args = apply_filters('rest_url_details_http_request_args', $args, $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
234
235 $response = wp_safe_remote_get($url, $args);
236
237 if (\WP_Http::OK !== wp_remote_retrieve_response_code($response)) {
238 // Not saving the error response to cache since the error might be temporary.
239 return new \WP_Error(
240 'no_response',
241 __('URL not found. Response returned a non-200 status code for this URL.', 'fluent-community'),
242 array('status' => \WP_Http::NOT_FOUND)
243 );
244 }
245
246 $remote_body = wp_remote_retrieve_body($response);
247
248 if (empty($remote_body)) {
249 return new \WP_Error(
250 'no_content',
251 __('Unable to retrieve body from response at this URL.', 'fluent-community'),
252 array('status' => \WP_Http::NOT_FOUND)
253 );
254 }
255
256 return $remote_body;
257 }
258
259 private function getDocumentHead($html)
260 {
261 $head_html = $html;
262
263 // Find the opening `<head>` tag.
264 $head_start = strpos($html, '<head');
265 if (false === $head_start) {
266 // Didn't find it. Return the original HTML.
267 return $html;
268 }
269
270 // Find the closing `</head>` tag.
271 $head_end = strpos($head_html, '</head>');
272 if (false === $head_end) {
273 // Didn't find it. Find the opening `<body>` tag.
274 $head_end = strpos($head_html, '<body');
275
276 // Didn't find it. Return the original HTML.
277 if (false === $head_end) {
278 return $html;
279 }
280 }
281
282 // Extract the HTML from opening tag to the closing tag. Then add the closing tag.
283 $head_html = substr($head_html, $head_start, $head_end);
284 $head_html .= '</head>';
285
286 return $head_html;
287 }
288
289 private function getDescription($meta_elements)
290 {
291 // Bail out if there are no meta elements.
292 if (empty($meta_elements[0])) {
293 return '';
294 }
295
296 $description = $this->getMetadataFromMetaElement(
297 $meta_elements,
298 'name',
299 '(?:description|og:description)'
300 );
301
302 // Bail out if description not found.
303 if ('' === $description) {
304 return '';
305 }
306
307 return $this->prepare_metadata_for_output($description);
308 }
309
310 private function getImage($meta_elements, $url)
311 {
312 $image = $this->getMetadataFromMetaElement(
313 $meta_elements,
314 'property',
315 '(?:og:image|og:image:url)'
316 );
317
318 // Bail out if image not found.
319 if ('' === $image) {
320 return '';
321 }
322
323 // Attempt to convert relative URLs to absolute.
324 $parsed_url = wp_parse_url($url);
325 if (isset($parsed_url['scheme']) && isset($parsed_url['host'])) {
326 $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
327 $image = \WP_Http::make_absolute_url($image, $root_url);
328 }
329
330 if (!$image) {
331 return $image;
332 }
333
334 return sanitize_url(html_entity_decode($image, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
335 }
336
337 private function getTitle($html)
338 {
339 if (!$html) {
340 return '';
341 }
342
343 $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is';
344 preg_match($pattern, $html, $match_title);
345
346 if (empty($match_title[1]) || !is_string($match_title[1])) {
347 return '';
348 }
349
350 $title = trim($match_title[1]);
351
352 return $this->prepare_metadata_for_output($title);
353 }
354
355 private function getMetaWithContentElements($html)
356 {
357 $pattern = '#<meta\s' .
358 '[^>]*' .
359 'content=(["\']??)(.*)\1' .
360 '[^>]*' .
361 '\/?>#' .
362 'isU';
363
364 preg_match_all($pattern, $html, $elements);
365
366 return $elements;
367 }
368
369 private function prepare_metadata_for_output($metadata)
370 {
371 $metadata = html_entity_decode($metadata, ENT_QUOTES, get_bloginfo('charset'));
372 $metadata = wp_strip_all_tags($metadata);
373 return $metadata;
374 }
375
376 private function getMetadataFromMetaElement($meta_elements, $attr, $attr_value)
377 {
378 // Bail out if there are no meta elements.
379 if (empty($meta_elements[0])) {
380 return '';
381 }
382
383 $metadata = '';
384 $pattern = '#' .
385 $attr . '=([\"\']??)\s*' . $attr_value . '\s*\1' .
386 '#isU';
387
388 foreach ($meta_elements[0] as $index => $element) {
389 preg_match($pattern, $element, $match);
390
391 if (empty($match)) {
392 continue;
393 }
394
395 if (isset($meta_elements[2][$index]) && is_string($meta_elements[2][$index])) {
396 $metadata = trim($meta_elements[2][$index]);
397 }
398
399 break;
400 }
401
402 return $metadata;
403 }
404
405 private function getIcon($html, $url)
406 {
407 // Grab the icon's link element.
408 $pattern = '#<link\s[^>]*rel=(?:[\"\']??)\s*(?:icon|shortcut icon|icon shortcut)\s*(?:[\"\']??)[^>]*\/?>#isU';
409 preg_match($pattern, $html, $element);
410 if (empty($element[0]) || !is_string($element[0])) {
411 return '';
412 }
413 $element = trim($element[0]);
414
415 // Get the icon's href value.
416 $pattern = '#href=([\"\']??)([^\" >]*?)\\1[^>]*#isU';
417 preg_match($pattern, $element, $icon);
418 if (empty($icon[2]) || !is_string($icon[2])) {
419 return '';
420 }
421 $icon = trim($icon[2]);
422
423 // If the icon is a data URL, return it.
424 $parsed_icon = wp_parse_url($icon);
425 if (isset($parsed_icon['scheme']) && 'data' === $parsed_icon['scheme']) {
426 return $icon;
427 }
428
429 // Attempt to convert relative URLs to absolute.
430 if (!is_string($url) || '' === $url) {
431 return $icon;
432 }
433
434 $parsed_url = wp_parse_url($url);
435 if (isset($parsed_url['scheme']) && isset($parsed_url['host'])) {
436 $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
437 $icon = \WP_Http::make_absolute_url($icon, $root_url);
438 }
439
440 return $icon;
441 }
442 }
443