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-inbox.php

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

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