PluginProbe
ActivityPub / 4.1.0
ActivityPub v4.1.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-comment.php

class-comment.php in ActivityPub 4.1.0, at includes/class-comment.php

708 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ActivityPub Comment Class
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Users;
11 use WP_Comment_Query;
12
13 /**
14 * ActivityPub Comment Class.
15 *
16 * This class is a helper/utils class that provides a collection of static
17 * methods that are used to handle comments.
18 */
19 class Comment {
20 /**
21 * Initialize the class, registering WordPress hooks.
22 */
23 public static function init() {
24 self::register_comment_types();
25
26 \add_filter( 'comment_reply_link', array( self::class, 'comment_reply_link' ), 10, 3 );
27 \add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
28 \add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 );
29 \add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_scripts' ) );
30 \add_action( 'pre_get_comments', array( static::class, 'comment_query' ) );
31 \add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 10, 2 );
32 \add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 );
33 }
34
35 /**
36 * Filter the comment reply link.
37 *
38 * We don't want to show the comment reply link for federated comments
39 * if the user is disabled for federation.
40 *
41 * @param string $link The HTML markup for the comment reply link.
42 * @param array $args An array of arguments overriding the defaults.
43 * @param \WP_Comment $comment The object of the comment being replied.
44 *
45 * @return string The filtered HTML markup for the comment reply link.
46 */
47 public static function comment_reply_link( $link, $args, $comment ) {
48 if ( self::are_comments_allowed( $comment ) ) {
49 $user_id = get_current_user_id();
50 if ( $user_id && self::was_received( $comment ) && \user_can( $user_id, 'activitypub' ) ) {
51 return self::create_fediverse_reply_link( $link, $args );
52 }
53
54 return $link;
55 }
56
57 $attrs = array(
58 'selectedComment' => self::generate_id( $comment ),
59 'commentId' => $comment->comment_ID,
60 );
61
62 $div = sprintf(
63 '<div class="activitypub-remote-reply" data-attrs="%s"></div>',
64 esc_attr( wp_json_encode( $attrs ) )
65 );
66
67 return apply_filters( 'activitypub_comment_reply_link', $div );
68 }
69
70 /**
71 * Create a link to reply to a federated comment.
72 *
73 * This function adds a title attribute to the reply link to inform the user
74 * that the comment was received from the fediverse and the reply will be sent
75 * to the original author.
76 *
77 * @param string $link The HTML markup for the comment reply link.
78 * @param array $args The args provided by the `comment_reply_link` filter.
79 *
80 * @return string The modified HTML markup for the comment reply link.
81 */
82 private static function create_fediverse_reply_link( $link, $args ) {
83 $str_to_replace = sprintf( '>%s<', $args['reply_text'] );
84 $replace_with = sprintf(
85 ' title="%s">%s<',
86 esc_attr__( 'This comment was received from the fediverse and your reply will be sent to the original author', 'activitypub' ),
87 esc_html__( 'Reply with federation', 'activitypub' )
88 );
89 return str_replace( $str_to_replace, $replace_with, $link );
90 }
91
92 /**
93 * Check if it is allowed to comment to a comment.
94 *
95 * Checks if the comment is local only or if the user can comment federated comments.
96 *
97 * @param mixed $comment Comment object or ID.
98 *
99 * @return boolean True if the user can comment, false otherwise.
100 */
101 public static function are_comments_allowed( $comment ) {
102 $comment = \get_comment( $comment );
103
104 if ( ! self::was_received( $comment ) ) {
105 return true;
106 }
107
108 $current_user = get_current_user_id();
109
110 if ( ! $current_user ) {
111 return false;
112 }
113
114 if ( is_single_user() && \user_can( $current_user, 'publish_posts' ) ) {
115 // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
116 $current_user = Users::BLOG_USER_ID;
117 }
118
119 $is_user_disabled = is_user_disabled( $current_user );
120
121 if ( $is_user_disabled ) {
122 return false;
123 }
124
125 return true;
126 }
127
128 /**
129 * Check if a comment is federated.
130 *
131 * We consider a comment federated if comment was received via ActivityPub.
132 *
133 * Use this function to check if it is comment that was received via ActivityPub.
134 *
135 * @param mixed $comment Comment object or ID.
136 *
137 * @return boolean True if the comment is federated, false otherwise.
138 */
139 public static function was_received( $comment ) {
140 $comment = \get_comment( $comment );
141
142 if ( ! $comment ) {
143 return false;
144 }
145
146 $protocol = \get_comment_meta( $comment->comment_ID, 'protocol', true );
147
148 if ( 'activitypub' === $protocol ) {
149 return true;
150 }
151
152 return false;
153 }
154
155 /**
156 * Check if a comment was federated.
157 *
158 * This function checks if a comment was federated via ActivityPub.
159 *
160 * @param mixed $comment Comment object or ID.
161 *
162 * @return boolean True if the comment was federated, false otherwise.
163 */
164 public static function was_sent( $comment ) {
165 $comment = \get_comment( $comment );
166
167 if ( ! $comment ) {
168 return false;
169 }
170
171 $status = \get_comment_meta( $comment->comment_ID, 'activitypub_status', true );
172
173 if ( $status ) {
174 return true;
175 }
176
177 return false;
178 }
179
180 /**
181 * Check if a comment is local only.
182 *
183 * This function checks if a comment is local only and was not sent or received via ActivityPub.
184 *
185 * @param mixed $comment Comment object or ID.
186 *
187 * @return boolean True if the comment is local only, false otherwise.
188 */
189 public static function is_local( $comment ) {
190 if ( self::was_sent( $comment ) || self::was_received( $comment ) ) {
191 return false;
192 }
193
194 return true;
195 }
196
197 /**
198 * Check if a comment should be federated.
199 *
200 * We consider a comment should be federated if it is authored by a user that is
201 * not disabled for federation and if it is a reply directly to the post or to a
202 * federated comment.
203 *
204 * Use this function to check if a comment should be federated.
205 *
206 * @param mixed $comment Comment object or ID.
207 *
208 * @return boolean True if the comment should be federated, false otherwise.
209 */
210 public static function should_be_federated( $comment ) {
211 // We should not federate federated comments.
212 if ( self::was_received( $comment ) ) {
213 return false;
214 }
215
216 $comment = \get_comment( $comment );
217 $user_id = $comment->user_id;
218
219 // Comments without user can't be federated.
220 if ( ! $user_id ) {
221 return false;
222 }
223
224 if ( is_single_user() && \user_can( $user_id, 'publish_posts' ) ) {
225 // On a single user site, comments by users with the `publish_posts` capability will be federated as the blog user.
226 $user_id = Users::BLOG_USER_ID;
227 }
228
229 $is_user_disabled = is_user_disabled( $user_id );
230
231 // User is disabled for federation.
232 if ( $is_user_disabled ) {
233 return false;
234 }
235
236 // It is a comment to the post and can be federated.
237 if ( empty( $comment->comment_parent ) ) {
238 return true;
239 }
240
241 // Check if parent comment is federated.
242 $parent_comment = \get_comment( $comment->comment_parent );
243
244 return ! self::is_local( $parent_comment );
245 }
246
247 /**
248 * Examine a comment ID and look up an existing comment it represents.
249 *
250 * @param string $id ActivityPub object ID (usually a URL) to check.
251 *
252 * @return \WP_Comment|false Comment object, or false on failure.
253 */
254 public static function object_id_to_comment( $id ) {
255 $comment_query = new WP_Comment_Query(
256 array(
257 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
258 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
259 )
260 );
261
262 if ( ! $comment_query->comments ) {
263 return false;
264 }
265
266 if ( count( $comment_query->comments ) > 1 ) {
267 return false;
268 }
269
270 return $comment_query->comments[0];
271 }
272
273 /**
274 * Verify if URL is a local comment, or if it is a previously received
275 * remote comment (For threading comments locally).
276 *
277 * @param string $url The URL to check.
278 *
279 * @return string|null Comment ID or null if not found.
280 */
281 public static function url_to_commentid( $url ) {
282 if ( ! $url || ! filter_var( $url, \FILTER_VALIDATE_URL ) ) {
283 return null;
284 }
285
286 // Check for local comment.
287 if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) === \wp_parse_url( $url, \PHP_URL_HOST ) ) {
288 $query = \wp_parse_url( $url, \PHP_URL_QUERY );
289
290 if ( $query ) {
291 parse_str( $query, $params );
292
293 if ( ! empty( $params['c'] ) ) {
294 $comment = \get_comment( $params['c'] );
295
296 if ( $comment ) {
297 return $comment->comment_ID;
298 }
299 }
300 }
301 }
302
303 $args = array(
304 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
305 'meta_query' => array(
306 'relation' => 'OR',
307 array(
308 'key' => 'source_url',
309 'value' => $url,
310 ),
311 array(
312 'key' => 'source_id',
313 'value' => $url,
314 ),
315 ),
316 );
317
318 $query = new WP_Comment_Query();
319 $comments = $query->query( $args );
320
321 if ( $comments && is_array( $comments ) ) {
322 return $comments[0]->comment_ID;
323 }
324
325 return null;
326 }
327
328 /**
329 * Filters the CSS classes to add an ActivityPub class.
330 *
331 * @param string[] $classes An array of comment classes.
332 * @param string[] $css_class An array of additional classes added to the list.
333 * @param string $comment_id The comment ID as a numeric string.
334 *
335 * @return string[] An array of classes.
336 */
337 public static function comment_class( $classes, $css_class, $comment_id ) {
338 // Check if ActivityPub comment.
339 if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
340 $classes[] = 'activitypub-comment';
341 }
342
343 return $classes;
344 }
345
346 /**
347 * Gets the public comment id via the WordPress comments meta.
348 *
349 * @param int $wp_comment_id The internal WordPress comment ID.
350 * @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
351 *
352 * @return string|null The ActivityPub id/url of the comment.
353 */
354 public static function get_source_id( $wp_comment_id, $fallback = true ) {
355 $comment_meta = \get_comment_meta( $wp_comment_id );
356
357 if ( ! empty( $comment_meta['source_id'][0] ) ) {
358 return $comment_meta['source_id'][0];
359 } elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
360 return $comment_meta['source_url'][0];
361 }
362
363 return null;
364 }
365
366 /**
367 * Gets the public comment url via the WordPress comments meta.
368 *
369 * @param int $wp_comment_id The internal WordPress comment ID.
370 * @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
371 *
372 * @return string|null The ActivityPub id/url of the comment.
373 */
374 public static function get_source_url( $wp_comment_id, $fallback = true ) {
375 $comment_meta = \get_comment_meta( $wp_comment_id );
376
377 if ( ! empty( $comment_meta['source_url'][0] ) ) {
378 return $comment_meta['source_url'][0];
379 } elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
380 return $comment_meta['source_id'][0];
381 }
382
383 return null;
384 }
385
386 /**
387 * Link remote comments to source url.
388 *
389 * @param string $comment_link The comment link.
390 * @param object|\WP_Comment $comment The comment object.
391 *
392 * @return string $url
393 */
394 public static function remote_comment_link( $comment_link, $comment ) {
395 if ( ! $comment || is_admin() ) {
396 return $comment_link;
397 }
398
399 $public_comment_link = self::get_source_url( $comment->comment_ID );
400
401 return $public_comment_link ?? $comment_link;
402 }
403
404
405 /**
406 * Generates an ActivityPub URI for a comment
407 *
408 * @param \WP_Comment|int $comment A comment object or comment ID.
409 *
410 * @return string ActivityPub URI for comment
411 */
412 public static function generate_id( $comment ) {
413 $comment = \get_comment( $comment );
414
415 // Show external comment ID if it exists.
416 $public_comment_link = self::get_source_id( $comment->comment_ID );
417
418 if ( $public_comment_link ) {
419 return $public_comment_link;
420 }
421
422 // Generate URI based on comment ID.
423 return \add_query_arg( 'c', $comment->comment_ID, \trailingslashit( \home_url() ) );
424 }
425
426 /**
427 * Check if a post has remote comments
428 *
429 * @param int $post_id The post ID.
430 *
431 * @return bool True if the post has remote comments, false otherwise.
432 */
433 private static function post_has_remote_comments( $post_id ) {
434 $comments = \get_comments(
435 array(
436 'post_id' => $post_id,
437 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
438 'meta_query' => array(
439 'relation' => 'AND',
440 array(
441 'key' => 'protocol',
442 'value' => 'activitypub',
443 'compare' => '=',
444 ),
445 array(
446 'key' => 'source_id',
447 'compare' => 'EXISTS',
448 ),
449 ),
450 )
451 );
452
453 return ! empty( $comments );
454 }
455
456 /**
457 * Enqueue scripts for remote comments
458 */
459 public static function enqueue_scripts() {
460 if ( ! \is_singular() || \is_user_logged_in() ) {
461 // Only on single pages, only for logged-out users.
462 return;
463 }
464
465 if ( ! \post_type_supports( \get_post_type(), 'activitypub' ) ) {
466 // Post type does not support ActivityPub.
467 return;
468 }
469
470 if ( ! \comments_open() || ! \get_comments_number() ) {
471 // No comments, no need to load the script.
472 return;
473 }
474
475 if ( ! self::post_has_remote_comments( \get_the_ID() ) ) {
476 // No remote comments, no need to load the script.
477 return;
478 }
479
480 $handle = 'activitypub-remote-reply';
481 $data = array(
482 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
483 );
484 $js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
485 $asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply/index.asset.php';
486
487 if ( \file_exists( $asset_file ) ) {
488 $assets = require_once $asset_file;
489
490 \wp_enqueue_script(
491 $handle,
492 \plugins_url( 'build/remote-reply/index.js', __DIR__ ),
493 $assets['dependencies'],
494 $assets['version'],
495 true
496 );
497 \wp_add_inline_script( $handle, $js, 'before' );
498
499 \wp_enqueue_style(
500 $handle,
501 \plugins_url( 'build/remote-reply/style-index.css', __DIR__ ),
502 array( 'wp-components' ),
503 $assets['version']
504 );
505 }
506 }
507
508 /**
509 * Return the registered custom comment types.
510 *
511 * @return array The registered custom comment types
512 */
513 public static function get_comment_types() {
514 global $activitypub_comment_types;
515
516 return $activitypub_comment_types;
517 }
518
519 /**
520 * Is this a registered comment type.
521 *
522 * @param string $slug The name of the type.
523 * @return boolean True if registered.
524 */
525 public static function is_registered_comment_type( $slug ) {
526 $slug = strtolower( $slug );
527 $slug = sanitize_key( $slug );
528
529 return in_array( $slug, array_keys( self::get_comment_types() ), true );
530 }
531
532 /**
533 * Return the registered custom comment types names.
534 *
535 * @return array The registered custom comment type names.
536 */
537 public static function get_comment_type_names() {
538 return array_values( wp_list_pluck( self::get_comment_types(), 'type' ) );
539 }
540
541 /**
542 * Get a comment type.
543 *
544 * @param string $type The comment type.
545 *
546 * @return array The comment type.
547 */
548 public static function get_comment_type( $type ) {
549 $type = strtolower( $type );
550 $type = sanitize_key( $type );
551 $types = self::get_comment_types();
552
553 if ( in_array( $type, array_keys( $types ), true ) ) {
554 $type_array = $types[ $type ];
555 } else {
556 $type_array = array();
557 }
558
559 /**
560 * Filter the comment type.
561 *
562 * @param array $type_array The comment type.
563 */
564 return apply_filters( "activitypub_comment_type_{$type}", $type_array );
565 }
566
567 /**
568 * Get a comment type attribute.
569 *
570 * @param string $type The comment type.
571 * @param string $attr The attribute to get.
572 *
573 * @return mixed The value of the attribute.
574 */
575 public static function get_comment_type_attr( $type, $attr ) {
576 $type_array = self::get_comment_type( $type );
577
578 if ( $type_array && isset( $type_array[ $attr ] ) ) {
579 $value = $type_array[ $attr ];
580 } else {
581 $value = '';
582 }
583
584 /**
585 * Filter the comment type attribute.
586 *
587 * @param mixed $value The value of the attribute.
588 * @param string $type The comment type.
589 */
590 return apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
591 }
592
593 /**
594 * Register the comment types used by the ActivityPub plugin.
595 */
596 public static function register_comment_types() {
597 register_comment_type(
598 'announce',
599 array(
600 'label' => __( 'Reposts', 'activitypub' ),
601 'singular' => __( 'Repost', 'activitypub' ),
602 'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
603 'icon' => '♻️',
604 'class' => 'p-repost',
605 'type' => 'repost',
606 // translators: %1$s username, %2$s object format (post, audio, ...), %3$s URL, %4$s domain.
607 'excerpt' => __( '&hellip; reposted this!', 'activitypub' ),
608 )
609 );
610
611 register_comment_type(
612 'like',
613 array(
614 'label' => __( 'Likes', 'activitypub' ),
615 'singular' => __( 'Like', 'activitypub' ),
616 'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
617 'icon' => '👍',
618 'class' => 'p-like',
619 'type' => 'like',
620 // translators: %1$s username, %2$s object format (post, audio, ...), %3$s URL, %4$s domain.
621 'excerpt' => __( '&hellip; liked this!', 'activitypub' ),
622 )
623 );
624 }
625
626 /**
627 * Show avatars on Activities if set.
628 *
629 * @param array $types List of avatar enabled comment types.
630 *
631 * @return array show avatars on Activities
632 */
633 public static function get_avatar_comment_types( $types ) {
634 $comment_types = self::get_comment_type_names();
635 $types = array_merge( $types, $comment_types );
636
637 return array_unique( $types );
638 }
639
640 /**
641 * Excludes likes and reposts from comment queries.
642 *
643 * @author Jan Boddez
644 *
645 * @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
646 *
647 * @param WP_Comment_Query $query Comment count.
648 */
649 public static function comment_query( $query ) {
650 if ( ! $query instanceof WP_Comment_Query ) {
651 return;
652 }
653
654 if ( is_admin() || ! is_singular() ) {
655 return;
656 }
657
658 if ( ! empty( $query->query_vars['type__in'] ) ) {
659 return;
660 }
661
662 if ( isset( $query->query_vars['count'] ) && true === $query->query_vars['count'] ) {
663 return;
664 }
665
666 // Exclude likes and reposts by the ActivityPub plugin.
667 $query->query_vars['type__not_in'] = self::get_comment_type_names();
668 }
669
670 /**
671 * Filter the comment status before it is set.
672 *
673 * @param string $approved The approved comment status.
674 * @param array $commentdata The comment data.
675 *
676 * @return boolean `true` if the comment is approved, `false` otherwise.
677 */
678 public static function pre_comment_approved( $approved, $commentdata ) {
679 if ( $approved || \is_wp_error( $approved ) ) {
680 return $approved;
681 }
682
683 if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
684 return $approved;
685 }
686
687 if (
688 empty( $commentdata['comment_meta']['protocol'] ) ||
689 'activitypub' !== $commentdata['comment_meta']['protocol']
690 ) {
691 return $approved;
692 }
693
694 global $wpdb;
695
696 $author = $commentdata['comment_author'];
697 $author_url = $commentdata['comment_author_url'];
698 // phpcs:ignore
699 $ok_to_comment = $wpdb->get_var( $wpdb->prepare( "SELECT comment_approved FROM $wpdb->comments WHERE comment_author = %s AND comment_author_url = %s and comment_approved = '1' LIMIT 1", $author, $author_url ) );
700
701 if ( 1 === (int) $ok_to_comment ) {
702 return 1;
703 }
704
705 return $approved;
706 }
707 }
708