PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.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 8.3.0, at includes/collection/class-inbox.php

557 lines 14.6 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 *
233 * @return bool|\WP_Error True on success, WP_Error on failure.
234 */
235 public static function undo( $id ) {
236 $inbox_item = self::get_by_guid( $id );
237
238 if ( \is_wp_error( $inbox_item ) ) {
239 // If inbox entry not found, return the error.
240 return $inbox_item;
241 }
242
243 $type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true );
244
245 switch ( $type ) {
246 case 'Follow':
247 $actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true );
248 $remote_actor = Remote_Actors::get_by_uri( $actor );
249
250 if ( \is_wp_error( $remote_actor ) ) {
251 return $remote_actor;
252 }
253
254 // A follow is only possible for a specific user.
255 $user_id = \get_post_meta( $inbox_item->ID, '_activitypub_user_id', true );
256 return Followers::remove( $remote_actor, $user_id );
257
258 case 'Like':
259 case 'Create':
260 case 'Announce':
261 if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) {
262 return new \WP_Error(
263 'activitypub_inbox_undo_interactions_disabled',
264 \__( 'Undo is not possible because incoming interactions are disabled.', 'activitypub' ),
265 array( 'status' => 403 )
266 );
267 }
268
269 $result = Comment::object_id_to_comment( esc_url_raw( $inbox_item->guid ) );
270
271 if ( empty( $result ) ) {
272 return new \WP_Error(
273 'activitypub_inbox_undo_comment_not_found',
274 \__( 'Undo is not possible because the comment was not found.', 'activitypub' ),
275 array( 'status' => 404 )
276 );
277 }
278
279 return \wp_delete_comment( $result, true );
280
281 default:
282 return new \WP_Error(
283 'activitypub_inbox_undo_unsupported',
284 // Translators: %s is the activity type.
285 \sprintf( \__( 'Undo is not supported for %s activities.', 'activitypub' ), $type ),
286 array( 'status' => 400 )
287 );
288 }
289 }
290
291 /**
292 * Get all recipients for an inbox activity.
293 *
294 * @param int $post_id The inbox post ID.
295 *
296 * @return array Array of user IDs who are recipients.
297 */
298 public static function get_recipients( $post_id ) {
299 // Get all meta values with key '_activitypub_user_id' (single => false).
300 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
301 $recipients = \array_map( 'intval', $recipients );
302
303 return $recipients;
304 }
305
306 /**
307 * Check if a user is a recipient of an inbox activity.
308 *
309 * @param int $post_id The inbox post ID.
310 * @param int $user_id The user ID to check.
311 *
312 * @return bool True if user is a recipient, false otherwise.
313 */
314 public static function has_recipient( $post_id, $user_id ) {
315 $recipients = self::get_recipients( $post_id );
316
317 return \in_array( (int) $user_id, $recipients, true );
318 }
319
320 /**
321 * Add a recipient to an existing inbox activity.
322 *
323 * @param int $post_id The inbox post ID.
324 * @param int $user_id The user ID to add.
325 *
326 * @return bool True on success, false on failure.
327 */
328 public static function add_recipient( $post_id, $user_id ) {
329 $user_id = (int) $user_id;
330 // Allow 0 for blog user, but reject negative values.
331 if ( $user_id < 0 ) {
332 return false;
333 }
334
335 // Check if already a recipient.
336 if ( self::has_recipient( $post_id, $user_id ) ) {
337 return true;
338 }
339
340 // Add new recipient as separate meta entry.
341 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
342 }
343
344 /**
345 * Remove a recipient from an inbox activity.
346 *
347 * @param int $post_id The inbox post ID.
348 * @param int $user_id The user ID to remove.
349 *
350 * @return bool True on success, false on failure.
351 */
352 public static function remove_recipient( $post_id, $user_id ) {
353 $user_id = (int) $user_id;
354
355 // Allow 0 for blog user, but reject negative values.
356 if ( $user_id < 0 ) {
357 return false;
358 }
359
360 // Delete the specific meta entry with this value.
361 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
362 }
363
364 /**
365 * Add multiple recipients to an existing inbox activity.
366 *
367 * @param int $post_id The inbox post ID.
368 * @param int[] $user_ids The user ID or array of user IDs to add.
369 */
370 public static function add_recipients( $post_id, $user_ids ) {
371 foreach ( $user_ids as $user_id ) {
372 self::add_recipient( $post_id, $user_id );
373 }
374 }
375
376 /**
377 * Get an inbox item by GUID for a specific recipient.
378 *
379 * This checks both that the activity exists and that the user is a valid recipient.
380 *
381 * @param string $guid The activity GUID.
382 * @param int $user_id The user ID.
383 *
384 * @return \WP_Post|\WP_Error The inbox item or WP_Error.
385 */
386 public static function get_by_guid_and_recipient( $guid, $user_id ) {
387 $post = self::get_by_guid( $guid );
388
389 if ( \is_wp_error( $post ) ) {
390 return $post;
391 }
392
393 // Check if user is a recipient.
394 if ( ! self::has_recipient( $post->ID, $user_id ) ) {
395 return new \WP_Error(
396 'activitypub_inbox_not_recipient',
397 'User is not a recipient of this activity',
398 array( 'status' => 404 )
399 );
400 }
401
402 return $post;
403 }
404
405 /**
406 * Get an inbox item by activity type and object ID.
407 *
408 * This is useful for finding specific activity types (like QuoteRequest)
409 * by their object identifier. For QuoteRequest activities, the object_id
410 * is the instrument URL (the quote post).
411 *
412 * @param string $activity_type The activity type (e.g., 'QuoteRequest').
413 * @param string $object_id The object identifier to search for.
414 *
415 * @return \WP_Post|\WP_Error The inbox item or WP_Error if not found.
416 */
417 public static function get_by_type_and_object( $activity_type, $object_id ) {
418 $posts = \get_posts(
419 array(
420 'post_type' => self::POST_TYPE,
421 'posts_per_page' => 1,
422 'orderby' => 'ID',
423 'order' => 'DESC',
424 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary for querying by activity type and object ID.
425 'meta_query' => array(
426 'relation' => 'AND',
427 array(
428 'key' => '_activitypub_activity_type',
429 'value' => $activity_type,
430 ),
431 array(
432 'key' => '_activitypub_object_id',
433 'value' => $object_id,
434 ),
435 ),
436 )
437 );
438
439 if ( empty( $posts ) ) {
440 return new \WP_Error(
441 'activitypub_inbox_item_not_found',
442 \__( 'Inbox item not found', 'activitypub' ),
443 array( 'status' => 404 )
444 );
445 }
446
447 return $posts[0];
448 }
449
450 /**
451 * Deduplicate inbox items with the same GUID.
452 *
453 * If multiple inbox items exist with the same GUID (due to race conditions),
454 * this merges all recipients into the first post and deletes duplicates.
455 *
456 * @param string $guid The activity GUID.
457 *
458 * @return \WP_Post|false The primary inbox post, or false if no posts found.
459 */
460 public static function deduplicate( $guid ) {
461 global $wpdb;
462
463 // Query for all posts with this GUID directly (get_posts doesn't supports guid parameter).
464 $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
465 $wpdb->prepare(
466 "SELECT ID FROM {$wpdb->posts} WHERE guid=%s AND post_type=%s ORDER BY ID ASC",
467 \esc_url( $guid ),
468 self::POST_TYPE
469 )
470 );
471
472 if ( empty( $post_ids ) ) {
473 return false;
474 }
475
476 // Keep the first (oldest) post as primary.
477 $primary_id = array_shift( $post_ids );
478 $primary = \get_post( $primary_id );
479
480 // Merge recipients from duplicates into primary and delete duplicates.
481 foreach ( $post_ids as $duplicate_id ) {
482 $recipients = \get_post_meta( $duplicate_id, '_activitypub_user_id', false );
483 self::add_recipients( $primary_id, $recipients );
484 \wp_delete_post( $duplicate_id, true );
485 }
486
487 return $primary;
488 }
489
490 /**
491 * Purge old inbox items.
492 *
493 * Deletes inbox items older than the specified number of days.
494 *
495 * @param int $days Number of days to keep items. Items older than this will be deleted.
496 *
497 * @return int The number of items deleted.
498 */
499 public static function purge( $days ) {
500 if ( $days <= 0 ) {
501 return 0;
502 }
503
504 $counts = \wp_count_posts( self::POST_TYPE );
505 $total = 0;
506 foreach ( $counts as $count ) {
507 $total += (int) $count;
508 }
509
510 if ( $total <= 200 ) {
511 return 0;
512 }
513
514 $deleted = 0;
515 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
516 $start_time = \time();
517
518 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
519 $overflow = $total > self::MAX_ITEMS;
520 $date_query = array(
521 array(
522 'before' => $cutoff,
523 ),
524 );
525
526 $query_args = array(
527 'post_type' => self::POST_TYPE,
528 'post_status' => 'any',
529 'fields' => 'ids',
530 'numberposts' => self::PURGE_BATCH_SIZE,
531 'orderby' => 'date',
532 'order' => 'ASC',
533 );
534
535 if ( ! $overflow ) {
536 $query_args['date_query'] = $date_query;
537 }
538
539 do {
540 $post_ids = \get_posts( $query_args );
541
542 foreach ( $post_ids as $post_id ) {
543 \wp_delete_post( $post_id, true );
544 ++$deleted;
545 }
546
547 // Once we're back under the cap, re-apply the date filter.
548 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
549 $overflow = false;
550 $query_args['date_query'] = $date_query;
551 }
552 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
553
554 return $deleted;
555 }
556 }
557