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

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