PluginProbe
ActivityPub / 5.7.0
ActivityPub v5.7.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
← All changes | includes/collection/class-outbox.php +45 -278 8.2.1 → 5.7.0 View file →
@@ -6,15 +6,14 @@
6 6 */
7 7
8 8 namespace Activitypub\Collection;
9 9
10 +use Activitypub\Dispatcher;
11 +use Activitypub\Scheduler;
10 12 use Activitypub\Activity\Activity;
11 13 use Activitypub\Activity\Base_Object;
12 -use Activitypub\Scheduler;
13 -use Activitypub\Webfinger;
14 14
15 15 use function Activitypub\add_to_outbox;
16 -use function Activitypub\object_to_uri;
17 16
18 17 /**
19 18 * ActivityPub Outbox Collection
20 19 *
@@ -20,48 +19,11 @@
20 19 *
21 20 * @link https://www.w3.org/TR/activitypub/#outbox
22 21 */
23 22 class Outbox {
24 - /**
25 - * The post type for the objects.
26 - *
27 - * @var string
28 - */
29 23 const POST_TYPE = 'ap_outbox';
30 24
31 25 /**
32 - * Maximum number of outbox items to keep.
33 - *
34 - * When the total count exceeds this, the oldest items are purged
35 - * regardless of their age. Acts as a safety net for runaway growth.
36 - *
37 - * @var int
38 - */
39 - const MAX_ITEMS = 5000;
40 -
41 - /**
42 - * Activity types included in the outbox collection listing.
43 - *
44 - * @var string[]
45 - */
46 - const ACTIVITY_TYPES = array( 'Announce', 'Arrive', 'Create', 'Like', 'Update' );
47 -
48 -
49 - /**
50 - * Number of items to process per batch during purge.
51 - *
52 - * @var int
53 - */
54 - const PURGE_BATCH_SIZE = 100;
55 -
56 - /**
57 - * Maximum seconds a purge run may take before yielding.
58 - *
59 - * @var int
60 - */
61 - const PURGE_TIMEOUT = 30;
62 -
63 - /**
64 26 * Add an Item to the outbox.
65 27 *
66 28 * @param Activity $activity Full Activity object that will be added to the outbox.
67 29 * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox.
@@ -70,35 +32,15 @@
70 32 * @return false|int|\WP_Error The added item or an error.
71 33 */
72 34 public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
73 35 $actor_type = Actors::get_type_by_id( $user_id );
36 + $object_id = self::get_object_id( $activity );
37 + $title = self::get_object_title( $activity->get_object() );
74 38
75 39 if ( ! $activity->get_actor() ) {
76 40 $activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
77 41 }
78 42
79 - $object_id = object_to_uri( self::get_object_id( $activity ) );
80 - $title = self::get_object_title( $activity->get_object() );
81 -
82 - if ( ! $object_id || ! \is_string( $object_id ) ) {
83 - return new \WP_Error(
84 - 'activitypub_outbox_invalid_object_id',
85 - \__( 'Unable to determine an object ID for this activity.', 'activitypub' ),
86 - array( 'status' => 400 )
87 - );
88 - }
89 -
90 - if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) {
91 - $object_id = Webfinger::resolve( $object_id );
92 - }
93 -
94 - if ( \is_wp_error( $object_id ) ) {
95 - return $object_id;
96 - }
97 -
98 - // Save activity in the context of an activitypub request.
99 - \add_filter( 'activitypub_is_activitypub_request', '__return_true' );
100 -
101 43 $outbox_item = array(
102 44 'post_type' => self::POST_TYPE,
103 45 'post_title' => sprintf(
104 46 /* translators: 1. Activity type, 2. Object Title or Excerpt */
@@ -105,10 +47,9 @@
105 47 __( '[%1$s] %2$s', 'activitypub' ),
106 48 $activity->get_type(),
107 49 \wp_trim_words( $title, 5 )
108 50 ),
109 - // Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`.
110 - 'post_content' => wp_slash( $activity->to_json( true, true ) ),
51 + 'post_content' => wp_slash( $activity->to_json() ),
111 52 // ensure that user ID is not below 0.
112 53 'post_author' => \max( $user_id, 0 ),
113 54 'post_status' => 'pending',
114 55 'meta_input' => array(
@@ -118,10 +59,8 @@
118 59 'activitypub_content_visibility' => $visibility,
119 60 ),
120 61 );
121 62
122 - \remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
123 -
124 63 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
125 64 if ( $has_kses ) {
126 65 // Prevent KSES from corrupting JSON in post_content.
127 66 \kses_remove_filters();
@@ -135,9 +74,9 @@
135 74
136 75 \wp_update_post(
137 76 array(
138 77 'ID' => $id,
139 - 'post_content' => \wp_slash( $activity->to_json( true, true ) ),
78 + 'post_content' => \wp_slash( $activity->to_json() ),
140 79 )
141 80 );
142 81 }
143 82
@@ -152,40 +91,26 @@
152 91 if ( ! $id ) {
153 92 return false;
154 93 }
155 94
156 - self::delete_superseded_items( $object_id, $activity->get_type(), $id );
95 + self::invalidate_existing_items( $object_id, $activity->get_type(), $id );
157 96
158 97 return $id;
159 98 }
160 99
161 100 /**
162 - * Delete pending outbox items that have been superseded by a newer item.
101 + * Invalidate existing outbox items with the same activity type and object ID
102 + * by setting their status to 'publish'.
163 103 *
164 - * For most activity types, only items with the same type and object ID are
165 - * deleted. Delete activities are a special case: they supersede all pending
166 - * items for the same object regardless of type.
104 + * @param string $object_id The ID of the activity object.
105 + * @param string $activity_type The type of the activity.
106 + * @param int $current_id The ID of the current outbox item to exclude.
167 107 *
168 - * Unschedules all federation events before deleting each item.
169 - * Skips Follow, Announce, Accept, and Reject activities, as those are
170 - * independent per-request responses that must not cancel each other.
171 - *
172 - * @param string $object_id The ActivityPub object ID (URL).
173 - * @param string $activity_type The activity type (e.g. 'Create', 'Update', 'Delete').
174 - * @param int $exclude_id The ID of the newly added outbox item to keep.
175 - *
176 108 * @return void
177 109 */
178 - private static function delete_superseded_items( $object_id, $activity_type, $exclude_id ) {
179 - /*
180 - * Do not delete items for Follow, Announce, Accept, or Reject activities.
181 - * Follow activities from different users share the same object ID but are
182 - * independent and must survive until their Accept is received.
183 - * Accept/Reject are per-request responses (e.g. to individual incoming
184 - * QuoteRequests) and must not cancel each other even when they share
185 - * the same object ID.
186 - */
187 - if ( in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject' ), true ) ) {
110 + private static function invalidate_existing_items( $object_id, $activity_type, $current_id ) {
111 + // Do not invalidate items for Announce activities.
112 + if ( 'Announce' === $activity_type ) {
188 113 return;
189 114 }
190 115
191 116 $meta_query = array(
@@ -194,10 +119,9 @@
194 119 'value' => $object_id,
195 120 ),
196 121 );
197 122
198 - // For non-Delete activities, only delete items of the same type.
199 - // Delete activities supersede all pending items for the same object.
123 + // For non-Delete activities, only invalidate items of the same type.
200 124 if ( 'Delete' !== $activity_type ) {
201 125 $meta_query[] = array(
202 126 'key' => '_activitypub_activity_type',
203 127 'value' => $activity_type,
@@ -207,9 +131,9 @@
207 131 $existing_items = get_posts(
208 132 array(
209 133 'post_type' => self::POST_TYPE,
210 134 'post_status' => 'pending',
211 - 'exclude' => array( $exclude_id ),
135 + 'exclude' => array( $current_id ),
212 136 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
213 137 'meta_query' => $meta_query,
214 138 'fields' => 'ids',
215 139 )
@@ -215,10 +139,23 @@
215 139 )
216 140 );
217 141
218 142 foreach ( $existing_items as $existing_item_id ) {
219 - Scheduler::unschedule_events_for_item( $existing_item_id );
220 - \wp_delete_post( $existing_item_id, true );
143 + $event_args = array(
144 + Dispatcher::$callback,
145 + $existing_item_id,
146 + Dispatcher::$batch_size,
147 + \get_post_meta( $existing_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
148 + );
149 +
150 + $timestamp = \wp_next_scheduled( 'activitypub_async_batch', $event_args );
151 + \wp_unschedule_event( $timestamp, 'activitypub_async_batch', $event_args );
152 +
153 + $timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $existing_item_id ) );
154 + \wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $existing_item_id ) );
155 +
156 + \wp_publish_post( $existing_item_id );
157 + \delete_post_meta( $existing_item_id, '_activitypub_outbox_offset' );
221 158 }
222 159 }
223 160
224 161 /**
@@ -225,18 +162,14 @@
225 162 * Creates an Undo activity.
226 163 *
227 164 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
228 165 *
229 - * @return int|bool|\WP_Error The ID of the outbox item or false on failure.
166 + * @return int|bool The ID of the outbox item or false on failure.
230 167 */
231 168 public static function undo( $outbox_item ) {
232 - $outbox_item = \get_post( $outbox_item );
169 + $outbox_item = get_post( $outbox_item );
233 170 $activity = self::get_activity( $outbox_item );
234 171
235 - if ( \is_wp_error( $activity ) ) {
236 - return $activity;
237 - }
238 -
239 172 $type = 'Undo';
240 173 if ( 'Create' === $activity->get_type() ) {
241 174 $type = 'Delete';
242 175 } elseif ( 'Add' === $activity->get_type() ) {
@@ -242,74 +175,12 @@
242 175 } elseif ( 'Add' === $activity->get_type() ) {
243 176 $type = 'Remove';
244 177 }
245 178
246 - $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
247 -
248 - return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility );
179 + return add_to_outbox( $activity, $type, $outbox_item->post_author );
249 180 }
250 181
251 182 /**
252 - * Get an outbox item by object ID and activity type.
253 - *
254 - * @param string $object_id The ActivityPub object ID.
255 - * @param string $activity_type The activity type (Create, Update, etc.).
256 - *
257 - * @return \WP_Post|null The outbox item or null if not found.
258 - */
259 - public static function get_by_object_id( $object_id, $activity_type ) {
260 - $outbox_items = \get_posts(
261 - array(
262 - 'post_type' => self::POST_TYPE,
263 - 'post_status' => 'any',
264 - 'posts_per_page' => 1,
265 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
266 - 'meta_query' => array(
267 - array(
268 - 'key' => '_activitypub_object_id',
269 - 'value' => $object_id,
270 - ),
271 - array(
272 - 'key' => '_activitypub_activity_type',
273 - 'value' => $activity_type,
274 - ),
275 - ),
276 - )
277 - );
278 -
279 - return ! empty( $outbox_items ) ? $outbox_items[0] : null;
280 - }
281 -
282 - /**
283 - * Get an outbox item by its GUID.
284 - *
285 - * @param string $guid The GUID of the outbox item.
286 - *
287 - * @return \WP_Post|\WP_Error The outbox item or WP_Error.
288 - */
289 - public static function get_by_guid( $guid ) {
290 - global $wpdb;
291 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
292 - $post_id = $wpdb->get_var(
293 - $wpdb->prepare(
294 - "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
295 - \esc_url( $guid ),
296 - self::POST_TYPE
297 - )
298 - );
299 -
300 - if ( ! $post_id ) {
301 - return new \WP_Error(
302 - 'activitypub_outbox_item_not_found',
303 - \__( 'Outbox item not found', 'activitypub' ),
304 - array( 'status' => 404 )
305 - );
306 - }
307 -
308 - return \get_post( $post_id );
309 - }
310 -
311 - /**
312 183 * Reschedule an activity.
313 184 *
314 185 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
315 186 *
@@ -334,16 +205,12 @@
334 205 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
335 206 * @return Activity|\WP_Error The Activity object or WP_Error.
336 207 */
337 208 public static function get_activity( $outbox_item ) {
338 - $outbox_item = \get_post( $outbox_item );
339 -
340 - if ( ! $outbox_item ) {
341 - return new \WP_Error(
342 - 'activitypub_outbox_item_not_found',
343 - \__( 'Outbox item not found.', 'activitypub' ),
344 - array( 'status' => 404 )
345 - );
209 + $outbox_item = get_post( $outbox_item );
210 + $actor = self::get_actor( $outbox_item );
211 + if ( is_wp_error( $actor ) ) {
212 + return $actor;
346 213 }
347 214
348 215 $activity_object = \json_decode( $outbox_item->post_content, true );
349 216 $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
@@ -350,20 +217,11 @@
350 217
351 218 if ( $activity_object['type'] === $type ) {
352 219 $activity = Activity::init_from_array( $activity_object );
353 220 if ( ! $activity->get_actor() ) {
354 - $actor = self::get_actor( $outbox_item );
355 - if ( \is_wp_error( $actor ) ) {
356 - return $actor;
357 - }
358 221 $activity->set_actor( $actor->get_id() );
359 222 }
360 223 } else {
361 - $actor = self::get_actor( $outbox_item );
362 - if ( \is_wp_error( $actor ) ) {
363 - return $actor;
364 - }
365 -
366 224 $activity = new Activity();
367 225 $activity->set_type( $type );
368 226 $activity->set_id( $outbox_item->guid );
369 227 $activity->set_actor( $actor->get_id() );
@@ -425,18 +283,8 @@
425 283 if ( 'ap_outbox' !== $outbox_item->post_type ) {
426 284 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
427 285 }
428 286
429 - // Authenticate via Bearer token for non-REST requests (e.g. permalink access).
430 - if ( \get_option( 'activitypub_api', false ) && ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) {
431 - \Activitypub\OAuth\Server::authenticate_oauth( null );
432 - }
433 -
434 - // Allow the author to view their own outbox items regardless of visibility.
435 - if ( \get_current_user_id() === (int) $outbox_item->post_author ) {
436 - return self::get_activity( $outbox_item );
437 - }
438 -
439 287 // Check if Outbox Activity is public.
440 288 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
441 289
442 290 if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
@@ -442,9 +290,9 @@
442 290 if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
443 291 return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
444 292 }
445 293
446 - $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES );
294 + $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
447 295 $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
448 296
449 297 if ( ! in_array( $activity_type, $activity_types, true ) ) {
450 298 return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
@@ -457,9 +305,9 @@
457 305 * Get the object ID of an activity.
458 306 *
459 307 * @param Activity|Base_Object|string $data The activity object.
460 308 *
461 - * @return string|null The object ID.
309 + * @return string The object ID.
462 310 */
463 311 private static function get_object_id( $data ) {
464 312 $object = $data->get_object();
465 313
@@ -470,19 +318,15 @@
470 318 if ( is_string( $object ) ) {
471 319 return $object;
472 320 }
473 321
474 - if ( $data->get_id() ) {
475 - return $data->get_id();
476 - }
477 -
478 - return object_to_uri( $data->get_actor() );
322 + return $data->get_id() ?? $data->get_actor();
479 323 }
480 324
481 325 /**
482 326 * Get the title of an activity recursively.
483 327 *
484 - * @param Activity|Base_Object $activity_object The activity object.
328 + * @param Base_Object $activity_object The activity object.
485 329 *
486 330 * @return string The title.
487 331 */
488 332 private static function get_object_title( $activity_object ) {
@@ -495,90 +339,13 @@
495 339
496 340 return $post_id ? get_the_title( $post_id ) : '';
497 341 }
498 342
499 - $title = $activity_object->get_name() ?: $activity_object->get_content();
343 + $title = $activity_object->get_name() ?? $activity_object->get_content();
500 344
501 345 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
502 - $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
346 + $title = $activity_object->get_object()->get_name() ?? $activity_object->get_object()->get_content();
503 347 }
504 348
505 349 return $title;
506 - }
507 -
508 - /**
509 - * Purge old outbox items.
510 - *
511 - * Deletes outbox items older than the specified number of days,
512 - * except for Follow activities which are always preserved.
513 - * Also enforces a hard cap on total items via MAX_ITEMS.
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 <= 20 ) {
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 - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
554 - 'meta_query' => array(
555 - array(
556 - 'key' => '_activitypub_activity_type',
557 - 'value' => 'Follow',
558 - 'compare' => '!=',
559 - ),
560 - ),
561 - );
562 -
563 - if ( ! $overflow ) {
564 - $query_args['date_query'] = $date_query;
565 - }
566 -
567 - do {
568 - $post_ids = \get_posts( $query_args );
569 -
570 - foreach ( $post_ids as $post_id ) {
571 - \wp_delete_post( $post_id, true );
572 - ++$deleted;
573 - }
574 -
575 - // Once we're back under the cap, re-apply the date filter.
576 - if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
577 - $overflow = false;
578 - $query_args['date_query'] = $date_query;
579 - }
580 - } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
581 -
582 - return $deleted;
583 350 }
584 351 }