PluginProbe
ActivityPub / 7.8.4
ActivityPub v7.8.4
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 7.8.4, at includes/collection/class-interactions.php

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