PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.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-inbox.php

class-inbox.php in ActivityPub 9.2.2, at includes/collection/class-inbox.php

588 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Inbox collection file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Collection;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Activity\Base_Object;
12 use Activitypub\Comment;
13
14 use function Activitypub\is_activity_public;
15 use function Activitypub\object_to_uri;
16
17 /**
18 * ActivityPub Inbox Collection
19 *
20 * @link https://www.w3.org/TR/activitypub/#inbox
21 */
22 class Inbox {
23 /**
24 * The post type for the objects.
25 *
26 * @var string
27 */
28 const POST_TYPE = 'ap_inbox';
29
30 /**
31 * Maximum number of inbox items to keep.
32 *
33 * @var int
34 */
35 const MAX_ITEMS = 5000;
36
37 /**
38 * Number of items to process per batch during purge.
39 *
40 * @var int
41 */
42 const PURGE_BATCH_SIZE = 100;
43
44 /**
45 * Maximum seconds a purge run may take before yielding.
46 *
47 * @var int
48 */
49 const PURGE_TIMEOUT = 30;
50
51 /**
52 * Context for user inbox requests.
53 *
54 * @var string
55 */
56 const CONTEXT_INBOX = 'inbox';
57
58 /**
59 * Context for shared inbox requests.
60 *
61 * @var string
62 */
63 const CONTEXT_SHARED_INBOX = 'shared_inbox';
64
65 /**
66 * Add an activity to the inbox.
67 *
68 * @param Activity|\WP_Error $activity The Activity object.
69 * @param int|array $recipients The id(s) of the local blog-user(s).
70 *
71 * @return false|int|\WP_Error The added item or an error.
72 */
73 public static function add( $activity, $recipients ) {
74 if ( \is_wp_error( $activity ) ) {
75 return $activity;
76 }
77
78 // Sanitize recipients.
79 $recipients = \array_map( 'absint', (array) $recipients );
80 $recipients = \array_unique( $recipients );
81 $recipients = \array_values( $recipients );
82
83 if ( empty( $recipients ) ) {
84 return new \WP_Error(
85 'activitypub_inbox_no_recipients',
86 'No valid recipients provided',
87 array( 'status' => 400 )
88 );
89 }
90
91 // Check if activity already exists (by GUID).
92 $existing = self::get_by_guid( $activity->get_id() );
93
94 // If activity exists, add new recipients to it.
95 if ( $existing instanceof \WP_Post ) {
96 foreach ( $recipients as $user_id ) {
97 self::add_recipient( $existing->ID, $user_id );
98 }
99
100 return $existing->ID;
101 }
102
103 // Activity doesn't exist, create new post.
104 $title = self::get_object_title( $activity->get_object() );
105 $visibility = is_activity_public( $activity ) ? ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC : ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
106
107 /*
108 * For QuoteRequest activities, we store the instrument URL as the object_id.
109 * This allows efficient querying by instrument (the quote post URL).
110 * For all other activities, we store the object URL as before.
111 */
112 if ( 'QuoteRequest' === $activity->get_type() && $activity->get_instrument() ) {
113 $object_id = object_to_uri( $activity->get_instrument() ?? '' );
114 } else {
115 $object_id = object_to_uri( $activity->get_object() ?? '' );
116 }
117
118 $inbox_item = array(
119 'post_type' => self::POST_TYPE,
120 'post_title' => \sprintf(
121 /* translators: 1. Activity type, 2. Object Title or Excerpt */
122 \__( '[%1$s] %2$s', 'activitypub' ),
123 $activity->get_type(),
124 \wp_trim_words( $title, 5 )
125 ),
126 // Persist the blind audience so we keep the full addressing the sender used.
127 'post_content' => \wp_slash( $activity->to_json( true, true ) ),
128 'post_author' => 0, // No specific author, recipients stored in meta.
129 'post_status' => 'publish',
130 // Store the GUID the way get_by_guid() looks it up, which is with esc_url(): an
131 // ampersand becomes `&#038;`. Passing it unescaped instead lets `pre_post_guid`
132 // store it as `&amp;`, and the two spellings never match.
133 'guid' => \esc_url( $activity->get_id() ),
134 'meta_input' => array(
135 '_activitypub_object_id' => $object_id,
136 '_activitypub_activity_type' => $activity->get_type(),
137 '_activitypub_activity_remote_actor' => object_to_uri( $activity->get_actor() ),
138 'activitypub_content_visibility' => $visibility,
139 ),
140 );
141
142 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
143 if ( $has_kses ) {
144 // Prevent KSES from corrupting JSON in post_content.
145 \kses_remove_filters();
146 }
147
148 $id = \wp_insert_post( $inbox_item, true );
149
150 if ( $has_kses ) {
151 \kses_init_filters();
152 }
153
154 // Add recipients as separate meta entries after post is created.
155 if ( ! \is_wp_error( $id ) ) {
156 foreach ( $recipients as $user_id ) {
157 self::add_recipient( $id, $user_id );
158 }
159 }
160
161 return $id;
162 }
163
164 /**
165 * Get the title of an activity recursively.
166 *
167 * @param Activity|Base_Object|array $activity_object The activity object.
168 *
169 * @return string The title.
170 */
171 private static function get_object_title( $activity_object ) {
172 if ( ! $activity_object || \is_array( $activity_object ) ) {
173 return '';
174 }
175
176 if ( \is_string( $activity_object ) ) {
177 $post_id = \url_to_postid( $activity_object );
178
179 return $post_id ? \get_the_title( $post_id ) : '';
180 }
181
182 $title = $activity_object->get_name() ?: $activity_object->get_content();
183
184 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
185 $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
186 }
187
188 return $title;
189 }
190
191 /**
192 * Get the inbox item by id.
193 *
194 * @param int $id The inbox item id.
195 *
196 * @return \WP_Post|null The inbox item or null.
197 */
198 public static function get( $id ) {
199 return \get_post( $id );
200 }
201
202 /**
203 * Get an inbox item by its GUID.
204 *
205 * @param string $guid The GUID of the inbox item.
206 *
207 * @return \WP_Post|\WP_Error The inbox item or WP_Error.
208 */
209 public static function get_by_guid( $guid ) {
210 global $wpdb;
211 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
212 $post_id = $wpdb->get_var(
213 $wpdb->prepare(
214 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
215 \esc_url( $guid ),
216 self::POST_TYPE
217 )
218 );
219
220 if ( ! $post_id ) {
221 return new \WP_Error(
222 'activitypub_inbox_item_not_found',
223 \__( 'Inbox item not found', 'activitypub' ),
224 array( 'status' => 404 )
225 );
226 }
227
228 return \get_post( $post_id );
229 }
230
231 /**
232 * Undo a received activity.
233 *
234 * @param string $id The ID of the inbox item to be removed.
235 * @param string|null $actor Optional. The actor URI of the Undo sender. When provided, the
236 * activity is only undone if this actor created the original
237 * activity. Default null.
238 *
239 * @return bool|\WP_Error True on success, WP_Error on failure.
240 */
241 public static function undo( $id, $actor = null ) {
242 $inbox_item = self::get_by_guid( $id );
243
244 if ( \is_wp_error( $inbox_item ) ) {
245 // If inbox entry not found, return the error.
246 return $inbox_item;
247 }
248
249 /*
250 * Only the actor that created the original activity may undo it. Without this
251 * binding a remote server could undo (and, for interactions, force-delete the
252 * comment behind) any activity whose public id it knows but does not own.
253 *
254 * Items stored before this meta existed have no actor recorded; those are let
255 * through for backward compatibility rather than becoming permanently un-undoable.
256 */
257 $stored_actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
258 if ( null !== $actor && $stored_actor && object_to_uri( $actor ) !== $stored_actor ) {
259 return new \WP_Error(
260 'activitypub_inbox_undo_forbidden',
261 \__( 'Undo is not possible because the actor does not own the activity.', 'activitypub' ),
262 array( 'status' => 403 )
263 );
264 }
265
266 $type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true );
267
268 switch ( $type ) {
269 case 'Follow':
270 $actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
271 $remote_actor = Remote_Actors::get_by_uri( $actor );
272
273 if ( \is_wp_error( $remote_actor ) ) {
274 return $remote_actor;
275 }
276
277 // A follow is only possible for a specific user.
278 $user_id = \get_post_meta( $inbox_item->ID, '_activitypub_user_id', true );
279 return Followers::remove( $remote_actor, $user_id );
280
281 case 'Like':
282 case 'Create':
283 case 'Announce':
284 if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
285 return new \WP_Error(
286 'activitypub_inbox_undo_interactions_disabled',
287 \__( 'Undo is not possible because incoming interactions are disabled.', 'activitypub' ),
288 array( 'status' => 403 )
289 );
290 }
291
292 /*
293 * The comment stores the activity ID as it arrived, so undo the escaping applied
294 * to the GUID before comparing the two. Only the sequences that escaping can
295 * produce are reversed, plus the `&amp;` that rows written before it carry:
296 * decoding the full entity set would also rewrite a `&lt;` or `&quot;` that an ID
297 * happens to contain as literal text, which escaping never put there.
298 */
299 $decoded = \str_replace( array( '&#038;', '&amp;', '&#039;' ), array( '&', '&', "'" ), $inbox_item->guid );
300 $result = Comment::object_id_to_comment( \esc_url_raw( $decoded ) );
301
302 if ( empty( $result ) ) {
303 return new \WP_Error(
304 'activitypub_inbox_undo_comment_not_found',
305 \__( 'Undo is not possible because the comment was not found.', 'activitypub' ),
306 array( 'status' => 404 )
307 );
308 }
309
310 return \wp_delete_comment( $result, true );
311
312 default:
313 return new \WP_Error(
314 'activitypub_inbox_undo_unsupported',
315 // Translators: %s is the activity type.
316 \sprintf( \__( 'Undo is not supported for %s activities.', 'activitypub' ), $type ),
317 array( 'status' => 400 )
318 );
319 }
320 }
321
322 /**
323 * Get all recipients for an inbox activity.
324 *
325 * @param int $post_id The inbox post ID.
326 *
327 * @return array Array of user IDs who are recipients.
328 */
329 public static function get_recipients( $post_id ) {
330 // Get all meta values with key '_activitypub_user_id' (single => false).
331 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
332 $recipients = \array_map( 'intval', $recipients );
333
334 return $recipients;
335 }
336
337 /**
338 * Check if a user is a recipient of an inbox activity.
339 *
340 * @param int $post_id The inbox post ID.
341 * @param int $user_id The user ID to check.
342 *
343 * @return bool True if user is a recipient, false otherwise.
344 */
345 public static function has_recipient( $post_id, $user_id ) {
346 $recipients = self::get_recipients( $post_id );
347
348 return \in_array( (int) $user_id, $recipients, true );
349 }
350
351 /**
352 * Add a recipient to an existing inbox activity.
353 *
354 * @param int $post_id The inbox post ID.
355 * @param int $user_id The user ID to add.
356 *
357 * @return bool True on success, false on failure.
358 */
359 public static function add_recipient( $post_id, $user_id ) {
360 $user_id = (int) $user_id;
361 // Allow 0 for blog user, but reject negative values.
362 if ( $user_id < 0 ) {
363 return false;
364 }
365
366 // Check if already a recipient.
367 if ( self::has_recipient( $post_id, $user_id ) ) {
368 return true;
369 }
370
371 // Add new recipient as separate meta entry.
372 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
373 }
374
375 /**
376 * Remove a recipient from an inbox activity.
377 *
378 * @param int $post_id The inbox post ID.
379 * @param int $user_id The user ID to remove.
380 *
381 * @return bool True on success, false on failure.
382 */
383 public static function remove_recipient( $post_id, $user_id ) {
384 $user_id = (int) $user_id;
385
386 // Allow 0 for blog user, but reject negative values.
387 if ( $user_id < 0 ) {
388 return false;
389 }
390
391 // Delete the specific meta entry with this value.
392 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
393 }
394
395 /**
396 * Add multiple recipients to an existing inbox activity.
397 *
398 * @param int $post_id The inbox post ID.
399 * @param int[] $user_ids The user ID or array of user IDs to add.
400 */
401 public static function add_recipients( $post_id, $user_ids ) {
402 foreach ( $user_ids as $user_id ) {
403 self::add_recipient( $post_id, $user_id );
404 }
405 }
406
407 /**
408 * Get an inbox item by GUID for a specific recipient.
409 *
410 * This checks both that the activity exists and that the user is a valid recipient.
411 *
412 * @param string $guid The activity GUID.
413 * @param int $user_id The user ID.
414 *
415 * @return \WP_Post|\WP_Error The inbox item or WP_Error.
416 */
417 public static function get_by_guid_and_recipient( $guid, $user_id ) {
418 $post = self::get_by_guid( $guid );
419
420 if ( \is_wp_error( $post ) ) {
421 return $post;
422 }
423
424 // Check if user is a recipient.
425 if ( ! self::has_recipient( $post->ID, $user_id ) ) {
426 return new \WP_Error(
427 'activitypub_inbox_not_recipient',
428 'User is not a recipient of this activity',
429 array( 'status' => 404 )
430 );
431 }
432
433 return $post;
434 }
435
436 /**
437 * Get an inbox item by activity type and object ID.
438 *
439 * This is useful for finding specific activity types (like QuoteRequest)
440 * by their object identifier. For QuoteRequest activities, the object_id
441 * is the instrument URL (the quote post).
442 *
443 * @param string $activity_type The activity type (e.g., 'QuoteRequest').
444 * @param string $object_id The object identifier to search for.
445 *
446 * @return \WP_Post|\WP_Error The inbox item or WP_Error if not found.
447 */
448 public static function get_by_type_and_object( $activity_type, $object_id ) {
449 $posts = \get_posts(
450 array(
451 'post_type' => self::POST_TYPE,
452 'posts_per_page' => 1,
453 'orderby' => 'ID',
454 'order' => 'DESC',
455 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary for querying by activity type and object ID.
456 'meta_query' => array(
457 'relation' => 'AND',
458 array(
459 'key' => '_activitypub_activity_type',
460 'value' => $activity_type,
461 ),
462 array(
463 'key' => '_activitypub_object_id',
464 'value' => $object_id,
465 ),
466 ),
467 )
468 );
469
470 if ( empty( $posts ) ) {
471 return new \WP_Error(
472 'activitypub_inbox_item_not_found',
473 \__( 'Inbox item not found', 'activitypub' ),
474 array( 'status' => 404 )
475 );
476 }
477
478 return $posts[0];
479 }
480
481 /**
482 * Deduplicate inbox items with the same GUID.
483 *
484 * If multiple inbox items exist with the same GUID (due to race conditions),
485 * this merges all recipients into the first post and deletes duplicates.
486 *
487 * @param string $guid The activity GUID.
488 *
489 * @return \WP_Post|false The primary inbox post, or false if no posts found.
490 */
491 public static function deduplicate( $guid ) {
492 global $wpdb;
493
494 // Query for all posts with this GUID directly (get_posts doesn't supports guid parameter).
495 $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
496 $wpdb->prepare(
497 "SELECT ID FROM {$wpdb->posts} WHERE guid=%s AND post_type=%s ORDER BY ID ASC",
498 \esc_url( $guid ),
499 self::POST_TYPE
500 )
501 );
502
503 if ( empty( $post_ids ) ) {
504 return false;
505 }
506
507 // Keep the first (oldest) post as primary.
508 $primary_id = \array_shift( $post_ids );
509 $primary = \get_post( $primary_id );
510
511 // Merge recipients from duplicates into primary and delete duplicates.
512 foreach ( $post_ids as $duplicate_id ) {
513 $recipients = \get_post_meta( $duplicate_id, '_activitypub_user_id', false );
514 self::add_recipients( $primary_id, $recipients );
515 \wp_delete_post( $duplicate_id, true );
516 }
517
518 return $primary;
519 }
520
521 /**
522 * Purge old inbox items.
523 *
524 * Deletes inbox items older than the specified number of days.
525 *
526 * @param int $days Number of days to keep items. Items older than this will be deleted.
527 *
528 * @return int The number of items deleted.
529 */
530 public static function purge( $days ) {
531 if ( $days <= 0 ) {
532 return 0;
533 }
534
535 $counts = \wp_count_posts( self::POST_TYPE );
536 $total = 0;
537 foreach ( $counts as $count ) {
538 $total += (int) $count;
539 }
540
541 if ( $total <= 200 ) {
542 return 0;
543 }
544
545 $deleted = 0;
546 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
547 $start_time = \time();
548
549 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
550 $overflow = $total > self::MAX_ITEMS;
551 $date_query = array(
552 array(
553 'before' => $cutoff,
554 ),
555 );
556
557 $query_args = array(
558 'post_type' => self::POST_TYPE,
559 'post_status' => 'any',
560 'fields' => 'ids',
561 'numberposts' => self::PURGE_BATCH_SIZE,
562 'orderby' => 'date',
563 'order' => 'ASC',
564 );
565
566 if ( ! $overflow ) {
567 $query_args['date_query'] = $date_query;
568 }
569
570 do {
571 $post_ids = \get_posts( $query_args );
572
573 foreach ( $post_ids as $post_id ) {
574 \wp_delete_post( $post_id, true );
575 ++$deleted;
576 }
577
578 // Once we're back under the cap, re-apply the date filter.
579 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
580 $overflow = false;
581 $query_args['date_query'] = $date_query;
582 }
583 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
584
585 return $deleted;
586 }
587 }
588