PluginProbe
Friends / trunk
Friends vtrunk
4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 2.9.2 All 87 releases
friends / includes / class-link-preview.php

class-link-preview.php in Friends trunk, at includes/class-link-preview.php

505 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 * Friends Link Preview
4 *
5 * This contains the functions for fetching and displaying link previews.
6 *
7 * @package Friends
8 */
9
10 namespace Friends;
11
12 /**
13 * This is the class for the link previews of the Friends Plugin.
14 *
15 * Incoming posts that just contain a link (for example Mastodon statuses that
16 * point to a news article) are enriched with the Open Graph metadata of that
17 * link so that a preview card can be displayed alongside the post.
18 *
19 * @since 4.3
20 *
21 * @package Friends
22 * @author Alex Kirk
23 */
24 class Link_Preview {
25 const META = 'link_preview';
26 const META_CHECKED = 'link_preview_checked';
27 const CRON_HOOK = 'friends_fetch_link_preview';
28
29 /**
30 * How long to wait before looking at a post again that had no preview.
31 */
32 const RETRY_AFTER = WEEK_IN_SECONDS;
33
34 /**
35 * How much of the remote document to download while looking for meta tags.
36 */
37 const MAX_RESPONSE_SIZE = 300000;
38
39 /**
40 * Contains a reference to the Friends class.
41 *
42 * @var Friends
43 */
44 private $friends = null;
45
46 /**
47 * Constructor
48 *
49 * @param Friends $friends A reference to the Friends object.
50 */
51 public function __construct( Friends $friends ) {
52 $this->friends = $friends;
53 $this->register_hooks();
54 }
55
56 /**
57 * Register the WordPress hooks
58 */
59 private function register_hooks() {
60 add_action( 'friends_retrieved_new_posts', array( $this, 'queue_new_posts' ), 10, 2 );
61 add_action( self::CRON_HOOK, array( $this, 'fetch_link_preview' ) );
62 }
63
64 /**
65 * Whether link previews should be fetched and displayed.
66 *
67 * @return bool True if enabled.
68 */
69 public static function is_enabled() {
70 return ! get_option( 'friends_disable_link_previews' );
71 }
72
73 /**
74 * Queue newly retrieved posts for a link preview lookup.
75 *
76 * @param User_Feed $user_feed The user feed the posts came from.
77 * @param array $new_post_ids The ids of the newly created posts.
78 */
79 public function queue_new_posts( $user_feed, $new_post_ids ) {
80 if ( ! self::is_enabled() || ! is_array( $new_post_ids ) ) {
81 return;
82 }
83
84 foreach ( $new_post_ids as $post_id ) {
85 self::queue( $post_id );
86 }
87 }
88
89 /**
90 * Schedule a link preview lookup for a post.
91 *
92 * @param int $post_id The post id.
93 */
94 public static function queue( $post_id ) {
95 $post_id = intval( $post_id );
96 if ( ! $post_id ) {
97 return;
98 }
99
100 if ( wp_next_scheduled( self::CRON_HOOK, array( $post_id ) ) ) {
101 return;
102 }
103
104 wp_schedule_single_event( time(), self::CRON_HOOK, array( $post_id ) );
105 }
106
107 /**
108 * Cron callback: look up the link preview for a post.
109 *
110 * @param int $post_id The post id.
111 */
112 public function fetch_link_preview( $post_id ) {
113 self::update_link_preview( $post_id );
114 }
115
116 /**
117 * Get the link preview for a post, scheduling a lookup if we don't have one yet.
118 *
119 * @param \WP_Post|int|null $post The post (defaults to the current post).
120 *
121 * @return array|false The link preview data or false if there is none.
122 */
123 public static function get_for_post( $post = null ) {
124 if ( ! self::is_enabled() ) {
125 return false;
126 }
127
128 $post = get_post( $post );
129 if ( ! $post ) {
130 return false;
131 }
132
133 $preview = get_post_meta( $post->ID, self::META, true );
134 if ( is_array( $preview ) && ! empty( $preview['url'] ) ) {
135 /**
136 * Filters the link preview data before it is displayed.
137 *
138 * @param array $preview The link preview data.
139 * @param \WP_Post $post The post the preview belongs to.
140 */
141 return apply_filters( 'friends_link_preview', $preview, $post );
142 }
143
144 $checked = intval( get_post_meta( $post->ID, self::META_CHECKED, true ) );
145 if ( ( ! $checked || $checked < time() - self::RETRY_AFTER ) && self::is_supported_post( $post ) ) {
146 self::queue( $post->ID );
147 }
148
149 return false;
150 }
151
152 /**
153 * Whether a post is a candidate for a link preview.
154 *
155 * @param \WP_Post $post The post.
156 *
157 * @return bool True if a preview could be shown for this post.
158 */
159 public static function is_supported_post( $post ) {
160 if ( ! in_array( $post->post_type, apply_filters( 'friends_frontend_post_types', array() ), true ) ) {
161 return false;
162 }
163
164 $post_format = get_post_format( $post );
165 if ( ! $post_format ) {
166 $post_format = 'standard';
167 }
168
169 /**
170 * Filters the post formats for which link previews are shown.
171 *
172 * @param array $post_formats The post formats.
173 * @param \WP_Post $post The post.
174 */
175 $post_formats = apply_filters( 'friends_link_preview_post_formats', array( 'status', 'link', 'aside' ), $post );
176
177 return in_array( $post_format, $post_formats, true );
178 }
179
180 /**
181 * Look up the link preview for a post and store it in post meta.
182 *
183 * @param int $post_id The post id.
184 * @param bool $force Whether to look it up even if we already did.
185 *
186 * @return array|false The link preview data or false if there is none.
187 */
188 public static function update_link_preview( $post_id, $force = false ) {
189 if ( ! self::is_enabled() ) {
190 return false;
191 }
192
193 $post = get_post( $post_id );
194 if ( ! $post || ! self::is_supported_post( $post ) ) {
195 return false;
196 }
197
198 if ( ! $force ) {
199 $checked = intval( get_post_meta( $post->ID, self::META_CHECKED, true ) );
200 if ( $checked && $checked > time() - self::RETRY_AFTER ) {
201 $preview = get_post_meta( $post->ID, self::META, true );
202 return is_array( $preview ) && ! empty( $preview['url'] ) ? $preview : false;
203 }
204 }
205
206 update_post_meta( $post->ID, self::META_CHECKED, time() );
207
208 $url = self::extract_url( $post );
209 if ( ! $url ) {
210 update_post_meta( $post->ID, self::META, array() );
211 return false;
212 }
213
214 $preview = self::fetch( $url );
215 if ( is_wp_error( $preview ) ) {
216 do_action( 'friends_link_preview_failed', $url, $preview, $post );
217 update_post_meta( $post->ID, self::META, array() );
218 return false;
219 }
220
221 update_post_meta( $post->ID, self::META, $preview );
222
223 return $preview;
224 }
225
226 /**
227 * Find the URL in a post that a preview should be shown for.
228 *
229 * Mirrors what Mastodon does: the first link in the post that is not a
230 * mention or a hashtag, and only if the post doesn't already show media.
231 *
232 * @param \WP_Post $post The post.
233 *
234 * @return string|false The url or false if there is none.
235 */
236 public static function extract_url( $post ) {
237 $content = $post->post_content;
238
239 if ( preg_match( '/<(?:img|video|audio|iframe|embed|object)[\s\/>]/i', $content ) ) {
240 // The post already displays media, no need for a preview card.
241 return false;
242 }
243
244 if ( ! preg_match_all( '/<a\s((?>[^>"\']+|"[^"]*"|\'[^\']*\')*)>/i', $content, $matches ) ) {
245 return false;
246 }
247
248 $own_host = wp_parse_url( $post->guid, PHP_URL_HOST );
249
250 foreach ( $matches[1] as $attributes ) {
251 if ( ! preg_match( '/\bhref\s*=\s*(["\'])(.*?)\1/is', $attributes, $m ) ) {
252 continue;
253 }
254 $url = html_entity_decode( trim( $m[2] ), ENT_QUOTES, get_bloginfo( 'charset' ) );
255
256 if ( preg_match( '/\bclass\s*=\s*(["\'])(.*?)\1/is', $attributes, $m ) ) {
257 $classes = preg_split( '/\s+/', strtolower( $m[2] ) );
258 if ( array_intersect( $classes, array( 'mention', 'hashtag', 'u-url' ) ) ) {
259 continue;
260 }
261 }
262
263 if ( preg_match( '/\brel\s*=\s*(["\'])(.*?)\1/is', $attributes, $m ) ) {
264 $rels = preg_split( '/\s+/', strtolower( $m[2] ) );
265 if ( array_intersect( $rels, array( 'tag', 'author' ) ) ) {
266 continue;
267 }
268 }
269
270 $parsed_url = wp_parse_url( $url );
271 if ( ! isset( $parsed_url['host'] ) || ! isset( $parsed_url['scheme'] ) ) {
272 continue;
273 }
274 if ( ! in_array( strtolower( $parsed_url['scheme'] ), array( 'http', 'https' ), true ) ) {
275 continue;
276 }
277 if ( $own_host && strtolower( $parsed_url['host'] ) === strtolower( $own_host ) ) {
278 // Don't preview the post itself or its neighbors on the same site.
279 continue;
280 }
281 if ( preg_match( '/\.(?:jpe?g|png|gif|webp|avif|svg|mp4|webm|mp3|ogg|pdf)$/i', $parsed_url['path'] ?? '' ) ) {
282 continue;
283 }
284
285 /**
286 * Filters the URL a link preview will be fetched for.
287 *
288 * Return false to not show a link preview for this post.
289 *
290 * @param string $url The url.
291 * @param \WP_Post $post The post.
292 */
293 return apply_filters( 'friends_link_preview_url', $url, $post );
294 }
295
296 return false;
297 }
298
299 /**
300 * Download a URL and extract its Open Graph metadata.
301 *
302 * @param string $url The url.
303 *
304 * @return array|\WP_Error The link preview data or an error.
305 */
306 public static function fetch( $url ) {
307 $response = wp_safe_remote_get(
308 $url,
309 array(
310 'timeout' => 10,
311 'redirection' => 3,
312 'limit_response_size' => self::MAX_RESPONSE_SIZE,
313 'headers' => array(
314 'Accept' => 'text/html,application/xhtml+xml',
315 ),
316 )
317 );
318
319 if ( is_wp_error( $response ) ) {
320 return $response;
321 }
322
323 $response_code = wp_remote_retrieve_response_code( $response );
324 if ( $response_code < 200 || $response_code >= 300 ) {
325 return new \WP_Error( 'friends_link_preview_http_error', wp_remote_retrieve_response_message( $response ), $response_code );
326 }
327
328 $content_type = wp_remote_retrieve_header( $response, 'content-type' );
329 if ( $content_type && false === stripos( $content_type, 'html' ) ) {
330 return new \WP_Error( 'friends_link_preview_not_html', $content_type );
331 }
332
333 $body = wp_remote_retrieve_body( $response );
334 if ( ! $body ) {
335 return new \WP_Error( 'friends_link_preview_empty_body' );
336 }
337
338 return self::parse( $body, self::get_final_url( $response, $url ), $url );
339 }
340
341 /**
342 * Get the URL a request ended up at after following redirects.
343 *
344 * @param array $response The response of wp_safe_remote_get.
345 * @param string $url The url that was requested.
346 *
347 * @return string The final url.
348 */
349 private static function get_final_url( $response, $url ) {
350 if ( ! isset( $response['http_response'] ) || ! is_object( $response['http_response'] ) ) {
351 return $url;
352 }
353 if ( ! method_exists( $response['http_response'], 'get_response_object' ) ) {
354 return $url;
355 }
356 $response_object = $response['http_response']->get_response_object();
357 if ( isset( $response_object->url ) && $response_object->url ) {
358 return $response_object->url;
359 }
360
361 return $url;
362 }
363
364 /**
365 * Extract the Open Graph metadata from an HTML document.
366 *
367 * @param string $html The HTML document.
368 * @param string $final_url The url the document was loaded from.
369 * @param string $url The url as it appeared in the post.
370 *
371 * @return array|\WP_Error The link preview data or an error.
372 */
373 public static function parse( $html, $final_url, $url = null ) {
374 if ( is_null( $url ) ) {
375 $url = $final_url;
376 }
377
378 $head = $html;
379 if ( preg_match( '/<head\b[^>]*>(.*?)<\/head>/is', $html, $m ) ) {
380 $head = $m[1];
381 }
382
383 $meta = array();
384 if ( preg_match_all( '/<meta\s((?>[^>"\']+|"[^"]*"|\'[^\']*\')*)\/?>/is', $head, $matches ) ) {
385 foreach ( $matches[1] as $attributes ) {
386 if ( ! preg_match( '/\b(?:property|name)\s*=\s*(["\'])\s*(.*?)\s*\1/is', $attributes, $m ) ) {
387 continue;
388 }
389 $key = strtolower( $m[2] );
390 if ( isset( $meta[ $key ] ) ) {
391 continue;
392 }
393 if ( ! preg_match( '/\bcontent\s*=\s*(["\'])(.*?)\1/is', $attributes, $m ) ) {
394 continue;
395 }
396 $meta[ $key ] = trim( html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' ) );
397 }
398 }
399
400 $title = self::first_of( $meta, array( 'og:title', 'twitter:title' ) );
401 if ( ! $title && preg_match( '/<title\b[^>]*>(.*?)<\/title>/is', $head, $m ) ) {
402 $title = trim( html_entity_decode( wp_strip_all_tags( $m[1] ), ENT_QUOTES, 'UTF-8' ) );
403 }
404
405 $description = self::first_of( $meta, array( 'og:description', 'twitter:description', 'description' ) );
406 $image = self::first_of( $meta, array( 'og:image:secure_url', 'og:image:url', 'og:image', 'twitter:image', 'twitter:image:src' ) );
407 $site_name = self::first_of( $meta, array( 'og:site_name', 'application-name' ) );
408
409 if ( $image ) {
410 $image = \WP_Http::make_absolute_url( $image, $final_url );
411 $scheme = wp_parse_url( $image, PHP_URL_SCHEME );
412 if ( ! in_array( strtolower( (string) $scheme ), array( 'http', 'https' ), true ) ) {
413 $image = '';
414 }
415 }
416
417 if ( ! $title && ! $description && ! $image ) {
418 return new \WP_Error( 'friends_link_preview_no_metadata', $url );
419 }
420
421 $preview = array(
422 'url' => $url,
423 'host' => self::pretty_host( $final_url ),
424 'title' => self::shorten( sanitize_text_field( $title ), 200 ),
425 'description' => self::shorten( sanitize_text_field( $description ), 400 ),
426 'image' => $image ? esc_url_raw( $image ) : '',
427 'site_name' => self::shorten( sanitize_text_field( $site_name ), 100 ),
428 );
429
430 foreach ( array( 'width', 'height' ) as $dimension ) {
431 $value = isset( $meta[ 'og:image:' . $dimension ] ) ? intval( $meta[ 'og:image:' . $dimension ] ) : 0;
432 if ( $value > 0 ) {
433 $preview[ 'image_' . $dimension ] = $value;
434 }
435 }
436
437 return $preview;
438 }
439
440 /**
441 * Get the first non-empty value of a list of keys.
442 *
443 * @param array $meta The metadata.
444 * @param array $keys The keys to look at, in order.
445 *
446 * @return string The value or an empty string.
447 */
448 private static function first_of( $meta, $keys ) {
449 foreach ( $keys as $key ) {
450 if ( ! empty( $meta[ $key ] ) ) {
451 return $meta[ $key ];
452 }
453 }
454
455 return '';
456 }
457
458 /**
459 * Shorten a string to a maximum length.
460 *
461 * @param string $text The text.
462 * @param int $length The maximum length.
463 *
464 * @return string The shortened text.
465 */
466 private static function shorten( $text, $length ) {
467 if ( mb_strlen( $text ) <= $length ) {
468 return $text;
469 }
470
471 return trim( mb_substr( $text, 0, $length - 1 ) ) . '';
472 }
473
474 /**
475 * Render the link preview card for the current post.
476 *
477 * @return string The HTML of the card or an empty string.
478 */
479 public static function render() {
480 if ( ! self::is_enabled() ) {
481 return '';
482 }
483
484 ob_start();
485 Friends::template_loader()->get_template_part( 'frontend/parts/link-preview', get_post_format(), array() );
486 return ob_get_clean();
487 }
488
489 /**
490 * Get the hostname of a URL without a leading www.
491 *
492 * @param string $url The url.
493 *
494 * @return string The hostname.
495 */
496 public static function pretty_host( $url ) {
497 $host = wp_parse_url( $url, PHP_URL_HOST );
498 if ( ! $host ) {
499 return '';
500 }
501
502 return preg_replace( '/^www\./i', '', $host );
503 }
504 }
505