PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.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 / collection / class-interactions.php

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

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