PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
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.0.2, at includes/collection/class-interactions.php

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