PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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 / collection / class-interactions.php

class-interactions.php in ActivityPub 8.2.1, at includes/collection/class-interactions.php

543 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Interactions collection file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Collection;
9
10 use Activitypub\Comment;
11 use Activitypub\Emoji;
12 use Activitypub\Webfinger;
13 use WP_Comment_Query;
14
15 use function Activitypub\get_remote_metadata_by_actor;
16 use function Activitypub\is_ap_post;
17 use function Activitypub\is_post_disabled;
18 use function Activitypub\object_id_to_comment;
19 use function Activitypub\object_to_uri;
20 use function Activitypub\url_to_commentid;
21
22 /**
23 * ActivityPub Interactions Collection.
24 */
25 class Interactions {
26 const INSERT = 'insert';
27 const UPDATE = 'update';
28
29 /**
30 * Add a comment to a post.
31 *
32 * When $user_id is provided, comment author data is built from the
33 * local WordPress user instead of fetching remote actor metadata.
34 *
35 * @param array $activity The activity-object.
36 * @param int|null $user_id Optional. Local user ID for outbox replies.
37 *
38 * @return int|false|\WP_Error The comment ID or false or WP_Error on failure.
39 */
40 public static function add_comment( $activity, $user_id = null ) {
41 $comment_data = self::activity_to_comment( $activity, $user_id );
42
43 if ( ! $comment_data ) {
44 return false;
45 }
46
47 // Determine target URL from reply or quote.
48 $parent_comment_id = 0;
49
50 if ( ! empty( $activity['object']['inReplyTo'] ) ) {
51 // Regular reply.
52 $target_url = object_to_uri( $activity['object']['inReplyTo'] );
53 $parent_comment_id = url_to_commentid( $target_url );
54 } else {
55 // Check for quote.
56 $target_url = self::get_quote_url( $activity );
57
58 if ( ! $target_url ) {
59 return false;
60 }
61
62 // Mark as quote and clean content.
63 $comment_data['comment_type'] = 'quote';
64
65 if ( ! empty( $activity['object']['content'] ) ) {
66 $pattern = '/<p[^>]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is';
67 $cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 );
68 $comment_data['comment_content'] = \wp_kses_post( $cleaned_content );
69 }
70 }
71
72 // Get post ID from target URL.
73 $target_url = \esc_url_raw( $target_url );
74 $comment_post_id = \url_to_postid( $target_url );
75
76 if ( ! $comment_post_id ) {
77 // Check for `ap_post`.
78 $comment_post = Remote_Posts::get_by_guid( $target_url );
79 if ( $comment_post instanceof \WP_Post ) {
80 $comment_post_id = $comment_post->ID;
81 }
82 }
83
84 // Handle nested replies (replies to comments).
85 if ( ! $comment_post_id && $parent_comment_id ) {
86 $parent_comment = \get_comment( $parent_comment_id );
87 $comment_post_id = $parent_comment->comment_post_ID;
88 }
89
90 if ( ! $comment_post_id ) {
91 // Not a reply to a post or comment.
92 return false;
93 }
94
95 $comment_data['comment_post_ID'] = $comment_post_id;
96 $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
97
98 return self::persist( $comment_data );
99 }
100
101 /**
102 * Update a comment.
103 *
104 * @param array $activity The activity object.
105 *
106 * @return array|string|int|\WP_Error|false The comment data or false on failure.
107 */
108 public static function update_comment( $activity ) {
109 $meta = get_remote_metadata_by_actor( $activity['actor'] );
110
111 if ( \is_wp_error( $meta ) || ! \is_array( $meta ) ) {
112 return $meta;
113 }
114
115 // Determine comment_ID.
116 $comment = object_id_to_comment( \esc_url_raw( $activity['object']['id'] ) );
117 $comment_data = \get_comment( $comment, ARRAY_A );
118
119 if ( ! $comment_data ) {
120 return false;
121 }
122
123 // Found a local comment id.
124 $comment_data['comment_author'] = \sanitize_text_field( empty( $meta['name'] ) ? $meta['preferredUsername'] : $meta['name'] );
125
126 /*
127 * Wrap emoji in content with blocks for runtime replacement.
128 * Note: Remote images in comments are stripped for security (only emoji allowed).
129 */
130 $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] );
131 $comment_data['comment_content'] = \addslashes( $content );
132
133 return self::persist( $comment_data, self::UPDATE );
134 }
135
136 /**
137 * Adds an incoming Like, Announce, ... as a comment to a post.
138 *
139 * @param array $activity Activity array.
140 *
141 * @return array|string|int|\WP_Error|false Comment data or `false` on failure.
142 */
143 public static function add_reaction( $activity ) {
144 $url = object_to_uri( $activity['object'] );
145 $comment_post_id = \url_to_postid( $url );
146 $parent_comment_id = url_to_commentid( $url );
147
148 if ( ! $comment_post_id ) {
149 // Check for `ap_post`.
150 $comment_post = Remote_Posts::get_by_guid( $url );
151 if ( $comment_post instanceof \WP_Post ) {
152 $comment_post_id = $comment_post->ID;
153 }
154 }
155
156 if ( ! $comment_post_id && $parent_comment_id ) {
157 $parent_comment = \get_comment( $parent_comment_id );
158 $comment_post_id = $parent_comment->comment_post_ID;
159 }
160
161 if ( ! $comment_post_id ) {
162 // Not a reply to a post or comment.
163 return false;
164 }
165
166 $comment_type = Comment::get_comment_type_by_activity_type( $activity['type'] );
167 if ( ! $comment_type ) {
168 // Not a valid comment type.
169 return false;
170 }
171
172 $comment_data = self::activity_to_comment( $activity );
173 if ( ! $comment_data ) {
174 return false;
175 }
176
177 $comment_data['comment_post_ID'] = $comment_post_id;
178 $comment_data['comment_parent'] = $parent_comment_id ? $parent_comment_id : 0;
179 $comment_data['comment_content'] = \esc_html( $comment_type['excerpt'] );
180 $comment_data['comment_type'] = \esc_attr( $comment_type['type'] );
181 $comment_data['comment_meta']['source_id'] = \esc_url_raw( $activity['id'] );
182
183 return self::persist( $comment_data );
184 }
185
186 /**
187 * Get interaction(s) by ID.
188 *
189 * @param string $url The URL/ID to get interactions for.
190 *
191 * @return array The interactions as WP_Comment objects.
192 */
193 public static function get_by_id( $url ) {
194 $args = array(
195 'nopaging' => true,
196 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
197 'meta_query' => array(
198 'relation' => 'AND',
199 array(
200 'key' => 'protocol',
201 'value' => 'activitypub',
202 ),
203 array(
204 'relation' => 'OR',
205 array(
206 'key' => 'source_url',
207 'value' => $url,
208 ),
209 array(
210 'key' => 'source_id',
211 'value' => $url,
212 ),
213 ),
214 ),
215 );
216
217 $query = new WP_Comment_Query( $args );
218 return $query->comments;
219 }
220
221 /**
222 * Get interaction(s) for a given URL/ID.
223 *
224 * @deprecated 7.6.0 Use {@see Interactions::get_by_id()}.
225 *
226 * @param string $url The URL/ID to get interactions for.
227 *
228 * @return array The interactions as WP_Comment objects.
229 */
230 public static function get_interaction_by_id( $url ) {
231 \_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_id' );
232
233 return self::get_by_id( $url );
234 }
235
236 /**
237 * Get interaction(s) by actor.
238 *
239 * @param string $actor The Actor-URL.
240 *
241 * @return array The interactions as WP_Comment objects.
242 */
243 public static function get_by_actor( $actor ) {
244 $meta = get_remote_metadata_by_actor( $actor );
245
246 // Get URL, because $actor seems to be the ID.
247 if ( $meta && ! is_wp_error( $meta ) && isset( $meta['url'] ) ) {
248 $actor = object_to_uri( $meta['url'] );
249 }
250
251 $args = array(
252 'nopaging' => true,
253 'author_url' => $actor,
254 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
255 'meta_query' => array(
256 array(
257 'key' => 'protocol',
258 'value' => 'activitypub',
259 ),
260 ),
261 );
262
263 return \get_comments( $args );
264 }
265
266 /**
267 * Get interaction(s) by remote actor ID.
268 *
269 * This is an optimized query that uses the remote actor post ID directly
270 * instead of querying by author_url.
271 *
272 * @param int $remote_actor_id The remote actor post ID.
273 *
274 * @return array The interactions as WP_Comment objects.
275 */
276 public static function get_by_remote_actor_id( $remote_actor_id ) {
277 $args = array(
278 'nopaging' => true,
279 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
280 'meta_query' => array(
281 'relation' => 'AND',
282 array(
283 'key' => 'protocol',
284 'value' => 'activitypub',
285 ),
286 array(
287 'key' => '_activitypub_remote_actor_id',
288 'value' => $remote_actor_id,
289 ),
290 ),
291 );
292
293 return \get_comments( $args );
294 }
295
296 /**
297 * Get interaction(s) for a given actor.
298 *
299 * @deprecated 7.6.0 Use {@see Interactions::get_by_actor()}.
300 *
301 * @param string $actor The Actor-URL.
302 *
303 * @return array The interactions as WP_Comment objects.
304 */
305 public static function get_interactions_by_actor( $actor ) {
306 \_deprecated_function( __METHOD__, '7.6.0', 'Activitypub\Collection\Interactions::get_by_actor' );
307
308 return self::get_by_actor( $actor );
309 }
310
311 /**
312 * Adds line breaks to the list of allowed comment tags.
313 *
314 * @param array $allowed_tags Allowed HTML tags.
315 * @param string $context Optional. Context. Default empty.
316 *
317 * @return array Filtered tag list.
318 */
319 public static function allowed_comment_html( $allowed_tags, $context = '' ) {
320 if ( 'pre_comment_content' !== $context ) {
321 // Do nothing.
322 return $allowed_tags;
323 }
324
325 // Add `p` and `br` to the list of allowed tags.
326 if ( ! array_key_exists( 'br', $allowed_tags ) ) {
327 $allowed_tags['br'] = array();
328 }
329
330 if ( ! array_key_exists( 'p', $allowed_tags ) ) {
331 $allowed_tags['p'] = array();
332 }
333
334 // Add `img` for custom emoji support with strict validation.
335 $emoji_html = Emoji::get_kses_allowed_html();
336 if ( ! array_key_exists( 'img', $allowed_tags ) ) {
337 $allowed_tags['img'] = $emoji_html['img'];
338 }
339
340 return $allowed_tags;
341 }
342
343 /**
344 * Convert an Activity to a WP_Comment.
345 *
346 * When $user_id is provided, comment author data is built from the
347 * local WordPress user instead of fetching remote actor metadata.
348 *
349 * @param array $activity The Activity array.
350 * @param int|null $user_id Optional. Local user ID for outbox comments.
351 *
352 * @return array|false The comment data or false on failure.
353 */
354 public static function activity_to_comment( $activity, $user_id = null ) {
355 $comment_content = null;
356
357 if ( $user_id ) {
358 // Outbox: resolve author from the local WordPress user.
359 $user = \get_userdata( $user_id );
360
361 if ( ! $user ) {
362 return false;
363 }
364
365 $comment_author = $user->display_name;
366 $comment_author_url = $user->user_url;
367 $comment_author_email = $user->user_email;
368 $comment_content = \wp_kses_post( $activity['object']['content'] ?? '' );
369 } else {
370 // S2S: resolve author from remote actor metadata.
371 $actor = object_to_uri( $activity['actor'] ?? null );
372 $actor = get_remote_metadata_by_actor( $actor );
373
374 if ( ! $actor || is_wp_error( $actor ) ) {
375 return false;
376 }
377
378 $comment_author = null;
379 if ( ! empty( $actor['name'] ) ) {
380 $comment_author = $actor['name'];
381 } elseif ( ! empty( $actor['preferredUsername'] ) ) {
382 $comment_author = $actor['preferredUsername'];
383 }
384
385 if ( empty( $comment_author ) && \get_option( 'require_name_email' ) ) {
386 return false;
387 }
388
389 $comment_author = $comment_author ?? __( 'Anonymous', 'activitypub' );
390 $comment_author_url = \esc_url_raw( object_to_uri( $actor['url'] ?? $actor['id'] ) );
391
392 $webfinger = Webfinger::uri_to_acct( $comment_author_url );
393 if ( is_wp_error( $webfinger ) ) {
394 $comment_author_email = '';
395 } else {
396 $comment_author_email = str_replace( 'acct:', '', $webfinger );
397 }
398
399 if ( isset( $activity['object']['content'] ) ) {
400 /*
401 * Wrap emoji in content with blocks for runtime replacement.
402 * Note: Remote images in comments are stripped for security (only emoji allowed).
403 */
404 $content = Emoji::wrap_in_content( $activity['object']['content'], $activity['object'] );
405 $comment_content = \addslashes( $content );
406 }
407 }
408
409 $published = $activity['object']['published'] ?? $activity['published'] ?? 'now';
410 $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $published ) );
411
412 $comment_data = array(
413 'comment_author' => $comment_author,
414 'comment_author_url' => $comment_author_url,
415 'comment_content' => $comment_content,
416 'comment_type' => 'comment',
417 'comment_author_email' => $comment_author_email,
418 'comment_date' => \get_date_from_gmt( $gm_date ),
419 'comment_date_gmt' => $gm_date,
420 'comment_meta' => array(),
421 );
422
423 if ( $user_id ) {
424 $comment_data['user_id'] = $user_id;
425 } else {
426 $comment_data['comment_meta']['protocol'] = 'activitypub';
427 $comment_data['comment_meta']['source_id'] = \esc_url_raw( object_to_uri( $activity['object'] ) );
428
429 // Store reference to remote actor post.
430 $actor_uri = object_to_uri( $activity['actor'] ?? null );
431 if ( $actor_uri ) {
432 $remote_actor = Remote_Actors::get_by_uri( $actor_uri );
433 if ( ! \is_wp_error( $remote_actor ) ) {
434 $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor->ID;
435 }
436 }
437
438 if ( isset( $activity['object']['url'] ) ) {
439 $comment_data['comment_meta']['source_url'] = \esc_url_raw( object_to_uri( $activity['object']['url'] ) );
440 }
441 }
442
443 return $comment_data;
444 }
445
446 /**
447 * Persist a comment.
448 *
449 * @param array $comment_data The comment data array.
450 * @param string $action Optional. Either 'insert' or 'update'. Default 'insert'.
451 *
452 * @return array|string|int|\WP_Error|false The comment data or false on failure
453 */
454 public static function persist( $comment_data, $action = self::INSERT ) {
455 if (
456 is_post_disabled( $comment_data['comment_post_ID'] ) &&
457 ! is_ap_post( $comment_data['comment_post_ID'] )
458 ) {
459 return false;
460 }
461
462 // Disable flood control.
463 \remove_action( 'check_comment_flood', 'check_comment_flood_db' );
464 // Do not require email for AP entries.
465 \add_filter( 'pre_option_require_name_email', '__return_false' );
466 // No nonce possible for this submission route.
467 \add_filter(
468 'akismet_comment_nonce',
469 static function () {
470 return 'inactive';
471 }
472 );
473 \add_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ), 10, 2 );
474
475 if ( self::INSERT === $action ) {
476 $state = \wp_new_comment( $comment_data, true );
477 } else {
478 $state = \wp_update_comment( $comment_data, true );
479 }
480
481 \remove_filter( 'wp_kses_allowed_html', array( self::class, 'allowed_comment_html' ) );
482 \remove_filter( 'pre_option_require_name_email', '__return_false' );
483 // Restore flood control.
484 \add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
485
486 if ( 1 === $state ) {
487 return $comment_data;
488 } else {
489 return $state; // Either WP_Comment, false, a WP_Error, 0, or 1!
490 }
491 }
492
493 /**
494 * Get the total number of interactions by type for a given ID.
495 *
496 * @param int $post_id The post ID.
497 * @param string $type The type of interaction to count.
498 *
499 * @return int The total number of interactions.
500 */
501 public static function count_by_type( $post_id, $type ) {
502 return \get_comments(
503 array(
504 'post_id' => $post_id,
505 'status' => 'approve',
506 'type' => $type,
507 'count' => true,
508 'paging' => false,
509 'fields' => 'ids',
510 )
511 );
512 }
513
514 /**
515 * Get the quote URL from an activity.
516 *
517 * Checks for quote properties in priority order: quote -> quoteUrl -> quoteUri -> _misskey_quote.
518 *
519 * @param array $activity The activity array.
520 *
521 * @return string|false The quote URL or false if not found.
522 */
523 public static function get_quote_url( $activity ) {
524 if ( ! empty( $activity['object']['quote'] ) ) {
525 return object_to_uri( $activity['object']['quote'] );
526 }
527
528 if ( ! empty( $activity['object']['quoteUrl'] ) ) {
529 return object_to_uri( $activity['object']['quoteUrl'] );
530 }
531
532 if ( ! empty( $activity['object']['quoteUri'] ) ) {
533 return object_to_uri( $activity['object']['quoteUri'] );
534 }
535
536 if ( ! empty( $activity['object']['_misskey_quote'] ) ) {
537 return object_to_uri( $activity['object']['_misskey_quote'] );
538 }
539
540 return false;
541 }
542 }
543