PluginProbe
ActivityPub / 9.3.1
ActivityPub v9.3.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 9.3.1, at includes/collection/class-interactions.php

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