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

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

577 lines 15.7 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 'guid' => $activity->get_id(),
131 'meta_input' => array(
132 '_activitypub_object_id' => $object_id,
133 '_activitypub_activity_type' => $activity->get_type(),
134 '_activitypub_activity_remote_actor' => object_to_uri( $activity->get_actor() ),
135 'activitypub_content_visibility' => $visibility,
136 ),
137 );
138
139 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
140 if ( $has_kses ) {
141 // Prevent KSES from corrupting JSON in post_content.
142 \kses_remove_filters();
143 }
144
145 $id = \wp_insert_post( $inbox_item, true );
146
147 if ( $has_kses ) {
148 \kses_init_filters();
149 }
150
151 // Add recipients as separate meta entries after post is created.
152 if ( ! \is_wp_error( $id ) ) {
153 foreach ( $recipients as $user_id ) {
154 self::add_recipient( $id, $user_id );
155 }
156 }
157
158 return $id;
159 }
160
161 /**
162 * Get the title of an activity recursively.
163 *
164 * @param Activity|Base_Object|array $activity_object The activity object.
165 *
166 * @return string The title.
167 */
168 private static function get_object_title( $activity_object ) {
169 if ( ! $activity_object || \is_array( $activity_object ) ) {
170 return '';
171 }
172
173 if ( \is_string( $activity_object ) ) {
174 $post_id = \url_to_postid( $activity_object );
175
176 return $post_id ? \get_the_title( $post_id ) : '';
177 }
178
179 $title = $activity_object->get_name() ?: $activity_object->get_content();
180
181 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
182 $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
183 }
184
185 return $title;
186 }
187
188 /**
189 * Get the inbox item by id.
190 *
191 * @param int $id The inbox item id.
192 *
193 * @return \WP_Post|null The inbox item or null.
194 */
195 public static function get( $id ) {
196 return \get_post( $id );
197 }
198
199 /**
200 * Get an inbox item by its GUID.
201 *
202 * @param string $guid The GUID of the inbox item.
203 *
204 * @return \WP_Post|\WP_Error The inbox item or WP_Error.
205 */
206 public static function get_by_guid( $guid ) {
207 global $wpdb;
208 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
209 $post_id = $wpdb->get_var(
210 $wpdb->prepare(
211 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
212 \esc_url( $guid ),
213 self::POST_TYPE
214 )
215 );
216
217 if ( ! $post_id ) {
218 return new \WP_Error(
219 'activitypub_inbox_item_not_found',
220 \__( 'Inbox item not found', 'activitypub' ),
221 array( 'status' => 404 )
222 );
223 }
224
225 return \get_post( $post_id );
226 }
227
228 /**
229 * Undo a received activity.
230 *
231 * @param string $id The ID of the inbox item to be removed.
232 * @param string|null $actor Optional. The actor URI of the Undo sender. When provided, the
233 * activity is only undone if this actor created the original
234 * activity. Default null.
235 *
236 * @return bool|\WP_Error True on success, WP_Error on failure.
237 */
238 public static function undo( $id, $actor = null ) {
239 $inbox_item = self::get_by_guid( $id );
240
241 if ( \is_wp_error( $inbox_item ) ) {
242 // If inbox entry not found, return the error.
243 return $inbox_item;
244 }
245
246 /*
247 * Only the actor that created the original activity may undo it. Without this
248 * binding a remote server could undo (and, for interactions, force-delete the
249 * comment behind) any activity whose public id it knows but does not own.
250 *
251 * Items stored before this meta existed have no actor recorded; those are let
252 * through for backward compatibility rather than becoming permanently un-undoable.
253 */
254 $stored_actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
255 if ( null !== $actor && $stored_actor && object_to_uri( $actor ) !== $stored_actor ) {
256 return new \WP_Error(
257 'activitypub_inbox_undo_forbidden',
258 \__( 'Undo is not possible because the actor does not own the activity.', 'activitypub' ),
259 array( 'status' => 403 )
260 );
261 }
262
263 $type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true );
264
265 switch ( $type ) {
266 case 'Follow':
267 $actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
268 $remote_actor = Remote_Actors::get_by_uri( $actor );
269
270 if ( \is_wp_error( $remote_actor ) ) {
271 return $remote_actor;
272 }
273
274 // A follow is only possible for a specific user.
275 $user_id = \get_post_meta( $inbox_item->ID, '_activitypub_user_id', true );
276 return Followers::remove( $remote_actor, $user_id );
277
278 case 'Like':
279 case 'Create':
280 case 'Announce':
281 if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
282 return new \WP_Error(
283 'activitypub_inbox_undo_interactions_disabled',
284 \__( 'Undo is not possible because incoming interactions are disabled.', 'activitypub' ),
285 array( 'status' => 403 )
286 );
287 }
288
289 $result = Comment::object_id_to_comment( \esc_url_raw( $inbox_item->guid ) );
290
291 if ( empty( $result ) ) {
292 return new \WP_Error(
293 'activitypub_inbox_undo_comment_not_found',
294 \__( 'Undo is not possible because the comment was not found.', 'activitypub' ),
295 array( 'status' => 404 )
296 );
297 }
298
299 return \wp_delete_comment( $result, true );
300
301 default:
302 return new \WP_Error(
303 'activitypub_inbox_undo_unsupported',
304 // Translators: %s is the activity type.
305 \sprintf( \__( 'Undo is not supported for %s activities.', 'activitypub' ), $type ),
306 array( 'status' => 400 )
307 );
308 }
309 }
310
311 /**
312 * Get all recipients for an inbox activity.
313 *
314 * @param int $post_id The inbox post ID.
315 *
316 * @return array Array of user IDs who are recipients.
317 */
318 public static function get_recipients( $post_id ) {
319 // Get all meta values with key '_activitypub_user_id' (single => false).
320 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
321 $recipients = \array_map( 'intval', $recipients );
322
323 return $recipients;
324 }
325
326 /**
327 * Check if a user is a recipient of an inbox activity.
328 *
329 * @param int $post_id The inbox post ID.
330 * @param int $user_id The user ID to check.
331 *
332 * @return bool True if user is a recipient, false otherwise.
333 */
334 public static function has_recipient( $post_id, $user_id ) {
335 $recipients = self::get_recipients( $post_id );
336
337 return \in_array( (int) $user_id, $recipients, true );
338 }
339
340 /**
341 * Add a recipient to an existing inbox activity.
342 *
343 * @param int $post_id The inbox post ID.
344 * @param int $user_id The user ID to add.
345 *
346 * @return bool True on success, false on failure.
347 */
348 public static function add_recipient( $post_id, $user_id ) {
349 $user_id = (int) $user_id;
350 // Allow 0 for blog user, but reject negative values.
351 if ( $user_id < 0 ) {
352 return false;
353 }
354
355 // Check if already a recipient.
356 if ( self::has_recipient( $post_id, $user_id ) ) {
357 return true;
358 }
359
360 // Add new recipient as separate meta entry.
361 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
362 }
363
364 /**
365 * Remove a recipient from an inbox activity.
366 *
367 * @param int $post_id The inbox post ID.
368 * @param int $user_id The user ID to remove.
369 *
370 * @return bool True on success, false on failure.
371 */
372 public static function remove_recipient( $post_id, $user_id ) {
373 $user_id = (int) $user_id;
374
375 // Allow 0 for blog user, but reject negative values.
376 if ( $user_id < 0 ) {
377 return false;
378 }
379
380 // Delete the specific meta entry with this value.
381 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
382 }
383
384 /**
385 * Add multiple recipients to an existing inbox activity.
386 *
387 * @param int $post_id The inbox post ID.
388 * @param int[] $user_ids The user ID or array of user IDs to add.
389 */
390 public static function add_recipients( $post_id, $user_ids ) {
391 foreach ( $user_ids as $user_id ) {
392 self::add_recipient( $post_id, $user_id );
393 }
394 }
395
396 /**
397 * Get an inbox item by GUID for a specific recipient.
398 *
399 * This checks both that the activity exists and that the user is a valid recipient.
400 *
401 * @param string $guid The activity GUID.
402 * @param int $user_id The user ID.
403 *
404 * @return \WP_Post|\WP_Error The inbox item or WP_Error.
405 */
406 public static function get_by_guid_and_recipient( $guid, $user_id ) {
407 $post = self::get_by_guid( $guid );
408
409 if ( \is_wp_error( $post ) ) {
410 return $post;
411 }
412
413 // Check if user is a recipient.
414 if ( ! self::has_recipient( $post->ID, $user_id ) ) {
415 return new \WP_Error(
416 'activitypub_inbox_not_recipient',
417 'User is not a recipient of this activity',
418 array( 'status' => 404 )
419 );
420 }
421
422 return $post;
423 }
424
425 /**
426 * Get an inbox item by activity type and object ID.
427 *
428 * This is useful for finding specific activity types (like QuoteRequest)
429 * by their object identifier. For QuoteRequest activities, the object_id
430 * is the instrument URL (the quote post).
431 *
432 * @param string $activity_type The activity type (e.g., 'QuoteRequest').
433 * @param string $object_id The object identifier to search for.
434 *
435 * @return \WP_Post|\WP_Error The inbox item or WP_Error if not found.
436 */
437 public static function get_by_type_and_object( $activity_type, $object_id ) {
438 $posts = \get_posts(
439 array(
440 'post_type' => self::POST_TYPE,
441 'posts_per_page' => 1,
442 'orderby' => 'ID',
443 'order' => 'DESC',
444 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary for querying by activity type and object ID.
445 'meta_query' => array(
446 'relation' => 'AND',
447 array(
448 'key' => '_activitypub_activity_type',
449 'value' => $activity_type,
450 ),
451 array(
452 'key' => '_activitypub_object_id',
453 'value' => $object_id,
454 ),
455 ),
456 )
457 );
458
459 if ( empty( $posts ) ) {
460 return new \WP_Error(
461 'activitypub_inbox_item_not_found',
462 \__( 'Inbox item not found', 'activitypub' ),
463 array( 'status' => 404 )
464 );
465 }
466
467 return $posts[0];
468 }
469
470 /**
471 * Deduplicate inbox items with the same GUID.
472 *
473 * If multiple inbox items exist with the same GUID (due to race conditions),
474 * this merges all recipients into the first post and deletes duplicates.
475 *
476 * @param string $guid The activity GUID.
477 *
478 * @return \WP_Post|false The primary inbox post, or false if no posts found.
479 */
480 public static function deduplicate( $guid ) {
481 global $wpdb;
482
483 // Query for all posts with this GUID directly (get_posts doesn't supports guid parameter).
484 $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
485 $wpdb->prepare(
486 "SELECT ID FROM {$wpdb->posts} WHERE guid=%s AND post_type=%s ORDER BY ID ASC",
487 \esc_url( $guid ),
488 self::POST_TYPE
489 )
490 );
491
492 if ( empty( $post_ids ) ) {
493 return false;
494 }
495
496 // Keep the first (oldest) post as primary.
497 $primary_id = \array_shift( $post_ids );
498 $primary = \get_post( $primary_id );
499
500 // Merge recipients from duplicates into primary and delete duplicates.
501 foreach ( $post_ids as $duplicate_id ) {
502 $recipients = \get_post_meta( $duplicate_id, '_activitypub_user_id', false );
503 self::add_recipients( $primary_id, $recipients );
504 \wp_delete_post( $duplicate_id, true );
505 }
506
507 return $primary;
508 }
509
510 /**
511 * Purge old inbox items.
512 *
513 * Deletes inbox items older than the specified number of days.
514 *
515 * @param int $days Number of days to keep items. Items older than this will be deleted.
516 *
517 * @return int The number of items deleted.
518 */
519 public static function purge( $days ) {
520 if ( $days <= 0 ) {
521 return 0;
522 }
523
524 $counts = \wp_count_posts( self::POST_TYPE );
525 $total = 0;
526 foreach ( $counts as $count ) {
527 $total += (int) $count;
528 }
529
530 if ( $total <= 200 ) {
531 return 0;
532 }
533
534 $deleted = 0;
535 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
536 $start_time = \time();
537
538 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
539 $overflow = $total > self::MAX_ITEMS;
540 $date_query = array(
541 array(
542 'before' => $cutoff,
543 ),
544 );
545
546 $query_args = array(
547 'post_type' => self::POST_TYPE,
548 'post_status' => 'any',
549 'fields' => 'ids',
550 'numberposts' => self::PURGE_BATCH_SIZE,
551 'orderby' => 'date',
552 'order' => 'ASC',
553 );
554
555 if ( ! $overflow ) {
556 $query_args['date_query'] = $date_query;
557 }
558
559 do {
560 $post_ids = \get_posts( $query_args );
561
562 foreach ( $post_ids as $post_id ) {
563 \wp_delete_post( $post_id, true );
564 ++$deleted;
565 }
566
567 // Once we're back under the cap, re-apply the date filter.
568 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
569 $overflow = false;
570 $query_args['date_query'] = $date_query;
571 }
572 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
573
574 return $deleted;
575 }
576 }
577