PluginProbe
ActivityPub / 9.0.2
ActivityPub v9.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-outbox.php

class-outbox.php in ActivityPub 9.0.2, at includes/collection/class-outbox.php

625 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Outbox 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\Scheduler;
13 use Activitypub\Webfinger;
14
15 use function Activitypub\add_to_outbox;
16 use function Activitypub\object_to_uri;
17 use function Activitypub\user_can_act_as_blog;
18
19 /**
20 * ActivityPub Outbox Collection
21 *
22 * @link https://www.w3.org/TR/activitypub/#outbox
23 */
24 class Outbox {
25 /**
26 * The post type for the objects.
27 *
28 * @var string
29 */
30 const POST_TYPE = 'ap_outbox';
31
32 /**
33 * Maximum number of outbox items to keep.
34 *
35 * When the total count exceeds this, the oldest items are purged
36 * regardless of their age. Acts as a safety net for runaway growth.
37 *
38 * @var int
39 */
40 const MAX_ITEMS = 5000;
41
42 /**
43 * Activity types included in the outbox collection listing.
44 *
45 * @var string[]
46 */
47 const ACTIVITY_TYPES = array( 'Announce', 'Arrive', 'Create', 'Like', 'Update' );
48
49
50 /**
51 * Number of items to process per batch during purge.
52 *
53 * @var int
54 */
55 const PURGE_BATCH_SIZE = 100;
56
57 /**
58 * Maximum seconds a purge run may take before yielding.
59 *
60 * @var int
61 */
62 const PURGE_TIMEOUT = 30;
63
64 /**
65 * Add an Item to the outbox.
66 *
67 * @param Activity $activity Full Activity object that will be added to the outbox.
68 * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox.
69 * @param string $visibility Optional. The visibility of the content. Default: `ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC`. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
70 *
71 * @return false|int|\WP_Error The added item or an error.
72 */
73 public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
74 $actor_type = Actors::get_type_by_id( $user_id );
75
76 if ( ! $activity->get_actor() ) {
77 $activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
78 }
79
80 $object_id = object_to_uri( self::get_object_id( $activity ) );
81 $title = self::get_object_title( $activity->get_object() );
82
83 if ( ! $object_id || ! \is_string( $object_id ) ) {
84 return new \WP_Error(
85 'activitypub_outbox_invalid_object_id',
86 \__( 'Unable to determine an object ID for this activity.', 'activitypub' ),
87 array( 'status' => 400 )
88 );
89 }
90
91 if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) {
92 $object_id = Webfinger::resolve( $object_id );
93 }
94
95 if ( \is_wp_error( $object_id ) ) {
96 return $object_id;
97 }
98
99 // Save activity in the context of an activitypub request.
100 \add_filter( 'activitypub_is_activitypub_request', '__return_true' );
101
102 $outbox_item = array(
103 'post_type' => self::POST_TYPE,
104 'post_title' => sprintf(
105 /* translators: 1. Activity type, 2. Object Title or Excerpt */
106 __( '[%1$s] %2$s', 'activitypub' ),
107 $activity->get_type(),
108 \wp_trim_words( $title, 5 )
109 ),
110 // Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`.
111 'post_content' => wp_slash( $activity->to_json( true, true ) ),
112 // ensure that user ID is not below 0.
113 'post_author' => \max( $user_id, 0 ),
114 'post_status' => 'pending',
115 'meta_input' => array(
116 '_activitypub_object_id' => $object_id,
117 '_activitypub_activity_type' => $activity->get_type(),
118 '_activitypub_activity_actor' => $actor_type,
119 'activitypub_content_visibility' => $visibility,
120 ),
121 );
122
123 \remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
124
125 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
126 if ( $has_kses ) {
127 // Prevent KSES from corrupting JSON in post_content.
128 \kses_remove_filters();
129 }
130
131 $id = \wp_insert_post( $outbox_item, true );
132
133 // Update the activity ID if the post was inserted successfully.
134 if ( $id && ! \is_wp_error( $id ) ) {
135 $activity->set_id( \get_the_guid( $id ) );
136
137 \wp_update_post(
138 array(
139 'ID' => $id,
140 'post_content' => \wp_slash( $activity->to_json( true, true ) ),
141 )
142 );
143 }
144
145 if ( $has_kses ) {
146 \kses_init_filters();
147 }
148
149 if ( \is_wp_error( $id ) ) {
150 return $id;
151 }
152
153 if ( ! $id ) {
154 return false;
155 }
156
157 self::delete_superseded_items( $object_id, $activity->get_type(), $id );
158
159 return $id;
160 }
161
162 /**
163 * Delete pending outbox items that have been superseded by a newer item.
164 *
165 * For most activity types, only items with the same type and object ID are
166 * deleted. Delete activities are a special case: they supersede all pending
167 * items for the same object regardless of type.
168 *
169 * Unschedules all federation events before deleting each item.
170 * Skips Follow, Announce, Accept, and Reject activities, as those are
171 * independent per-request responses that must not cancel each other.
172 *
173 * @param string $object_id The ActivityPub object ID (URL).
174 * @param string $activity_type The activity type (e.g. 'Create', 'Update', 'Delete').
175 * @param int $exclude_id The ID of the newly added outbox item to keep.
176 *
177 * @return void
178 */
179 private static function delete_superseded_items( $object_id, $activity_type, $exclude_id ) {
180 /*
181 * Do not delete items for Follow, Announce, Accept, or Reject activities.
182 * Follow activities from different users share the same object ID but are
183 * independent and must survive until their Accept is received.
184 * Accept/Reject are per-request responses (e.g. to individual incoming
185 * QuoteRequests) and must not cancel each other even when they share
186 * the same object ID.
187 */
188 if ( in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject' ), true ) ) {
189 return;
190 }
191
192 $meta_query = array(
193 array(
194 'key' => '_activitypub_object_id',
195 'value' => $object_id,
196 ),
197 );
198
199 /*
200 * Same-type pending items are always superseded. A confirmed
201 * re-publish (Create) additionally invalidates a pending Delete so
202 * we do not send both Delete and Create for the same object.
203 *
204 * Update is intentionally NOT in this list: it must not cancel a
205 * pending Delete, or an unrelated edit could flip a hidden object back
206 * to federated. An Update for an already-deleted object is rejected
207 * upstream in `add_to_outbox()`, and the scheduler re-publish path emits
208 * a Create (not an Update), so that legitimate path still cancels Delete.
209 */
210 if ( 'Delete' !== $activity_type ) {
211 $types = 'Create' === $activity_type
212 ? array( 'Create', 'Delete' )
213 : array( $activity_type );
214
215 $meta_query[] = array(
216 'key' => '_activitypub_activity_type',
217 'value' => $types,
218 'compare' => 'IN',
219 );
220 }
221
222 /*
223 * Delete wipes the entire outbox history for the object — any
224 * already-sent Create/Update/etc. is now stale and a redelivery
225 * retry would resurrect content we are tearing down. Other
226 * activity types only invalidate pending peers.
227 */
228 $status_filter = 'Delete' === $activity_type ? 'any' : 'pending';
229
230 $existing_items = get_posts(
231 array(
232 'post_type' => self::POST_TYPE,
233 'post_status' => $status_filter,
234 'exclude' => array( $exclude_id ),
235 'numberposts' => -1,
236 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
237 'meta_query' => $meta_query,
238 'fields' => 'ids',
239 )
240 );
241
242 foreach ( $existing_items as $existing_item_id ) {
243 Scheduler::unschedule_events_for_item( $existing_item_id );
244 \wp_delete_post( $existing_item_id, true );
245 }
246 }
247
248 /**
249 * Creates an Undo activity.
250 *
251 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
252 *
253 * @return int|bool|\WP_Error The ID of the outbox item or false on failure.
254 */
255 public static function undo( $outbox_item ) {
256 $outbox_item = \get_post( $outbox_item );
257 $activity = self::get_activity( $outbox_item );
258
259 if ( \is_wp_error( $activity ) ) {
260 return $activity;
261 }
262
263 $type = 'Undo';
264 if ( 'Create' === $activity->get_type() ) {
265 $type = 'Delete';
266 } elseif ( 'Add' === $activity->get_type() ) {
267 $type = 'Remove';
268 }
269
270 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
271
272 return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility );
273 }
274
275 /**
276 * Get an outbox item by object ID and activity type.
277 *
278 * @param string $object_id The ActivityPub object ID.
279 * @param string $activity_type The activity type (Create, Update, etc.).
280 *
281 * @return \WP_Post|null The outbox item or null if not found.
282 */
283 public static function get_by_object_id( $object_id, $activity_type ) {
284 $outbox_items = \get_posts(
285 array(
286 'post_type' => self::POST_TYPE,
287 'post_status' => 'any',
288 'posts_per_page' => 1,
289 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
290 'meta_query' => array(
291 array(
292 'key' => '_activitypub_object_id',
293 'value' => $object_id,
294 ),
295 array(
296 'key' => '_activitypub_activity_type',
297 'value' => $activity_type,
298 ),
299 ),
300 )
301 );
302
303 return ! empty( $outbox_items ) ? $outbox_items[0] : null;
304 }
305
306 /**
307 * Get an outbox item by its GUID.
308 *
309 * @param string $guid The GUID of the outbox item.
310 *
311 * @return \WP_Post|\WP_Error The outbox item or WP_Error.
312 */
313 public static function get_by_guid( $guid ) {
314 global $wpdb;
315 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
316 $post_id = $wpdb->get_var(
317 $wpdb->prepare(
318 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
319 \esc_url( $guid ),
320 self::POST_TYPE
321 )
322 );
323
324 if ( ! $post_id ) {
325 return new \WP_Error(
326 'activitypub_outbox_item_not_found',
327 \__( 'Outbox item not found', 'activitypub' ),
328 array( 'status' => 404 )
329 );
330 }
331
332 return \get_post( $post_id );
333 }
334
335 /**
336 * Reschedule an activity.
337 *
338 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
339 *
340 * @return bool True if the activity was rescheduled, false otherwise.
341 */
342 public static function reschedule( $outbox_item ) {
343 $outbox_item = get_post( $outbox_item );
344
345 $outbox_item->post_status = 'pending';
346 $outbox_item->post_date = current_time( 'mysql' );
347
348 wp_update_post( $outbox_item );
349
350 Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
351
352 return true;
353 }
354
355 /**
356 * Get the Activity object from the Outbox item.
357 *
358 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
359 * @return Activity|\WP_Error The Activity object or WP_Error.
360 */
361 public static function get_activity( $outbox_item ) {
362 $outbox_item = \get_post( $outbox_item );
363
364 if ( ! $outbox_item ) {
365 return new \WP_Error(
366 'activitypub_outbox_item_not_found',
367 \__( 'Outbox item not found.', 'activitypub' ),
368 array( 'status' => 404 )
369 );
370 }
371
372 $activity_object = \json_decode( $outbox_item->post_content, true );
373 $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
374
375 if ( $activity_object['type'] === $type ) {
376 $activity = Activity::init_from_array( $activity_object );
377 if ( ! $activity->get_actor() ) {
378 $actor = self::get_actor( $outbox_item );
379 if ( \is_wp_error( $actor ) ) {
380 return $actor;
381 }
382 $activity->set_actor( $actor->get_id() );
383 }
384 } else {
385 $actor = self::get_actor( $outbox_item );
386 if ( \is_wp_error( $actor ) ) {
387 return $actor;
388 }
389
390 $activity = new Activity();
391 $activity->set_type( $type );
392 $activity->set_id( $outbox_item->guid );
393 $activity->set_actor( $actor->get_id() );
394 // Pre-fill the Activity with data (for example cc and to).
395 $activity->set_object( $activity_object );
396 }
397
398 if ( 'Update' === $type ) {
399 $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
400 }
401
402 /**
403 * Filters the Activity object before it is returned.
404 *
405 * @param Activity $activity The Activity object.
406 * @param \WP_Post $outbox_item The outbox item post object.
407 */
408 return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
409 }
410
411 /**
412 * Get the Actor object from the Outbox item.
413 *
414 * @param \WP_Post $outbox_item The Outbox post.
415 *
416 * @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
417 */
418 public static function get_actor( $outbox_item ) {
419 $actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
420
421 switch ( $actor_type ) {
422 case 'blog':
423 $actor_id = Actors::BLOG_USER_ID;
424 break;
425 case 'application':
426 $actor_id = Actors::APPLICATION_USER_ID;
427 break;
428 case 'user':
429 default:
430 $actor_id = $outbox_item->post_author;
431 break;
432 }
433
434 return Actors::get_by_id( $actor_id );
435 }
436
437 /**
438 * Get the Activity object from the Outbox item.
439 *
440 * @param \WP_Post $outbox_item The Outbox post.
441 *
442 * @return Activity|\WP_Error The Activity object or WP_Error.
443 */
444 public static function maybe_get_activity( $outbox_item ) {
445 if ( ! $outbox_item instanceof \WP_Post ) {
446 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
447 }
448
449 if ( 'ap_outbox' !== $outbox_item->post_type ) {
450 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
451 }
452
453 // Authenticate via Bearer token for non-REST requests (e.g. permalink access).
454 if ( \get_option( 'activitypub_api', false ) && ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) {
455 \Activitypub\OAuth\Server::authenticate_oauth( null );
456 }
457
458 /*
459 * Allow the author to view their own outbox items regardless of visibility.
460 * The `is_user_logged_in()` guard prevents anonymous visitors from matching
461 * the blog actor's items (where both `get_current_user_id()` and `post_author`
462 * are `0`), which would otherwise expose private activities at their permalink.
463 *
464 * Users authorized to act as the blog actor are treated as the author of
465 * blog-actor items so they can read the same private outbox they can post to.
466 */
467 if ( \is_user_logged_in() ) {
468 $author = (int) $outbox_item->post_author;
469
470 if ( \get_current_user_id() === $author ) {
471 return self::get_activity( $outbox_item );
472 }
473
474 if ( Actors::BLOG_USER_ID === $author && user_can_act_as_blog() ) {
475 return self::get_activity( $outbox_item );
476 }
477 }
478
479 // Check if Outbox Activity is public.
480 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
481
482 if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
483 return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
484 }
485
486 $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES );
487 $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
488
489 if ( ! in_array( $activity_type, $activity_types, true ) ) {
490 return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
491 }
492
493 return self::get_activity( $outbox_item );
494 }
495
496 /**
497 * Get the object ID of an activity.
498 *
499 * @param Activity|Base_Object|string $data The activity object.
500 *
501 * @return string|null The object ID.
502 */
503 private static function get_object_id( $data ) {
504 $object = $data->get_object();
505
506 if ( is_object( $object ) ) {
507 return self::get_object_id( $object );
508 }
509
510 if ( is_string( $object ) ) {
511 return $object;
512 }
513
514 if ( $data->get_id() ) {
515 return $data->get_id();
516 }
517
518 return object_to_uri( $data->get_actor() );
519 }
520
521 /**
522 * Get the title of an activity recursively.
523 *
524 * @param Activity|Base_Object $activity_object The activity object.
525 *
526 * @return string The title.
527 */
528 private static function get_object_title( $activity_object ) {
529 if ( ! $activity_object ) {
530 return '';
531 }
532
533 if ( is_string( $activity_object ) ) {
534 $post_id = url_to_postid( $activity_object );
535
536 return $post_id ? get_the_title( $post_id ) : '';
537 }
538
539 $title = $activity_object->get_name() ?: $activity_object->get_content();
540
541 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
542 $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
543 }
544
545 return $title;
546 }
547
548 /**
549 * Purge old outbox items.
550 *
551 * Deletes outbox items older than the specified number of days,
552 * except for Follow activities which are always preserved.
553 * Also enforces a hard cap on total items via MAX_ITEMS.
554 *
555 * @param int $days Number of days to keep items. Items older than this will be deleted.
556 *
557 * @return int The number of items deleted.
558 */
559 public static function purge( $days ) {
560 if ( $days <= 0 ) {
561 return 0;
562 }
563
564 $counts = \wp_count_posts( self::POST_TYPE );
565 $total = 0;
566 foreach ( $counts as $count ) {
567 $total += (int) $count;
568 }
569
570 if ( $total <= 20 ) {
571 return 0;
572 }
573
574 $deleted = 0;
575 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
576 $start_time = \time();
577
578 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
579 $overflow = $total > self::MAX_ITEMS;
580 $date_query = array(
581 array(
582 'before' => $cutoff,
583 ),
584 );
585
586 $query_args = array(
587 'post_type' => self::POST_TYPE,
588 'post_status' => 'any',
589 'fields' => 'ids',
590 'numberposts' => self::PURGE_BATCH_SIZE,
591 'orderby' => 'date',
592 'order' => 'ASC',
593 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
594 'meta_query' => array(
595 array(
596 'key' => '_activitypub_activity_type',
597 'value' => 'Follow',
598 'compare' => '!=',
599 ),
600 ),
601 );
602
603 if ( ! $overflow ) {
604 $query_args['date_query'] = $date_query;
605 }
606
607 do {
608 $post_ids = \get_posts( $query_args );
609
610 foreach ( $post_ids as $post_id ) {
611 \wp_delete_post( $post_id, true );
612 ++$deleted;
613 }
614
615 // Once we're back under the cap, re-apply the date filter.
616 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
617 $overflow = false;
618 $query_args['date_query'] = $date_query;
619 }
620 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
621
622 return $deleted;
623 }
624 }
625