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.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.10.01, at app/Services/RemoteUrlParser.php

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