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

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

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