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

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

602 lines 16.9 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 // For non-Delete activities, only delete items of the same type.
200 // Delete activities supersede all pending items for the same object.
201 if ( 'Delete' !== $activity_type ) {
202 $meta_query[] = array(
203 'key' => '_activitypub_activity_type',
204 'value' => $activity_type,
205 );
206 }
207
208 $existing_items = get_posts(
209 array(
210 'post_type' => self::POST_TYPE,
211 'post_status' => 'pending',
212 'exclude' => array( $exclude_id ),
213 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
214 'meta_query' => $meta_query,
215 'fields' => 'ids',
216 )
217 );
218
219 foreach ( $existing_items as $existing_item_id ) {
220 Scheduler::unschedule_events_for_item( $existing_item_id );
221 \wp_delete_post( $existing_item_id, true );
222 }
223 }
224
225 /**
226 * Creates an Undo activity.
227 *
228 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
229 *
230 * @return int|bool|\WP_Error The ID of the outbox item or false on failure.
231 */
232 public static function undo( $outbox_item ) {
233 $outbox_item = \get_post( $outbox_item );
234 $activity = self::get_activity( $outbox_item );
235
236 if ( \is_wp_error( $activity ) ) {
237 return $activity;
238 }
239
240 $type = 'Undo';
241 if ( 'Create' === $activity->get_type() ) {
242 $type = 'Delete';
243 } elseif ( 'Add' === $activity->get_type() ) {
244 $type = 'Remove';
245 }
246
247 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
248
249 return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility );
250 }
251
252 /**
253 * Get an outbox item by object ID and activity type.
254 *
255 * @param string $object_id The ActivityPub object ID.
256 * @param string $activity_type The activity type (Create, Update, etc.).
257 *
258 * @return \WP_Post|null The outbox item or null if not found.
259 */
260 public static function get_by_object_id( $object_id, $activity_type ) {
261 $outbox_items = \get_posts(
262 array(
263 'post_type' => self::POST_TYPE,
264 'post_status' => 'any',
265 'posts_per_page' => 1,
266 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
267 'meta_query' => array(
268 array(
269 'key' => '_activitypub_object_id',
270 'value' => $object_id,
271 ),
272 array(
273 'key' => '_activitypub_activity_type',
274 'value' => $activity_type,
275 ),
276 ),
277 )
278 );
279
280 return ! empty( $outbox_items ) ? $outbox_items[0] : null;
281 }
282
283 /**
284 * Get an outbox item by its GUID.
285 *
286 * @param string $guid The GUID of the outbox item.
287 *
288 * @return \WP_Post|\WP_Error The outbox item or WP_Error.
289 */
290 public static function get_by_guid( $guid ) {
291 global $wpdb;
292 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
293 $post_id = $wpdb->get_var(
294 $wpdb->prepare(
295 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
296 \esc_url( $guid ),
297 self::POST_TYPE
298 )
299 );
300
301 if ( ! $post_id ) {
302 return new \WP_Error(
303 'activitypub_outbox_item_not_found',
304 \__( 'Outbox item not found', 'activitypub' ),
305 array( 'status' => 404 )
306 );
307 }
308
309 return \get_post( $post_id );
310 }
311
312 /**
313 * Reschedule an activity.
314 *
315 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
316 *
317 * @return bool True if the activity was rescheduled, false otherwise.
318 */
319 public static function reschedule( $outbox_item ) {
320 $outbox_item = get_post( $outbox_item );
321
322 $outbox_item->post_status = 'pending';
323 $outbox_item->post_date = current_time( 'mysql' );
324
325 wp_update_post( $outbox_item );
326
327 Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
328
329 return true;
330 }
331
332 /**
333 * Get the Activity object from the Outbox item.
334 *
335 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
336 * @return Activity|\WP_Error The Activity object or WP_Error.
337 */
338 public static function get_activity( $outbox_item ) {
339 $outbox_item = \get_post( $outbox_item );
340
341 if ( ! $outbox_item ) {
342 return new \WP_Error(
343 'activitypub_outbox_item_not_found',
344 \__( 'Outbox item not found.', 'activitypub' ),
345 array( 'status' => 404 )
346 );
347 }
348
349 $activity_object = \json_decode( $outbox_item->post_content, true );
350 $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
351
352 if ( $activity_object['type'] === $type ) {
353 $activity = Activity::init_from_array( $activity_object );
354 if ( ! $activity->get_actor() ) {
355 $actor = self::get_actor( $outbox_item );
356 if ( \is_wp_error( $actor ) ) {
357 return $actor;
358 }
359 $activity->set_actor( $actor->get_id() );
360 }
361 } else {
362 $actor = self::get_actor( $outbox_item );
363 if ( \is_wp_error( $actor ) ) {
364 return $actor;
365 }
366
367 $activity = new Activity();
368 $activity->set_type( $type );
369 $activity->set_id( $outbox_item->guid );
370 $activity->set_actor( $actor->get_id() );
371 // Pre-fill the Activity with data (for example cc and to).
372 $activity->set_object( $activity_object );
373 }
374
375 if ( 'Update' === $type ) {
376 $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
377 }
378
379 /**
380 * Filters the Activity object before it is returned.
381 *
382 * @param Activity $activity The Activity object.
383 * @param \WP_Post $outbox_item The outbox item post object.
384 */
385 return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
386 }
387
388 /**
389 * Get the Actor object from the Outbox item.
390 *
391 * @param \WP_Post $outbox_item The Outbox post.
392 *
393 * @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
394 */
395 public static function get_actor( $outbox_item ) {
396 $actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
397
398 switch ( $actor_type ) {
399 case 'blog':
400 $actor_id = Actors::BLOG_USER_ID;
401 break;
402 case 'application':
403 $actor_id = Actors::APPLICATION_USER_ID;
404 break;
405 case 'user':
406 default:
407 $actor_id = $outbox_item->post_author;
408 break;
409 }
410
411 return Actors::get_by_id( $actor_id );
412 }
413
414 /**
415 * Get the Activity object from the Outbox item.
416 *
417 * @param \WP_Post $outbox_item The Outbox post.
418 *
419 * @return Activity|\WP_Error The Activity object or WP_Error.
420 */
421 public static function maybe_get_activity( $outbox_item ) {
422 if ( ! $outbox_item instanceof \WP_Post ) {
423 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
424 }
425
426 if ( 'ap_outbox' !== $outbox_item->post_type ) {
427 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
428 }
429
430 // Authenticate via Bearer token for non-REST requests (e.g. permalink access).
431 if ( \get_option( 'activitypub_api', false ) && ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) {
432 \Activitypub\OAuth\Server::authenticate_oauth( null );
433 }
434
435 /*
436 * Allow the author to view their own outbox items regardless of visibility.
437 * The `is_user_logged_in()` guard prevents anonymous visitors from matching
438 * the blog actor's items (where both `get_current_user_id()` and `post_author`
439 * are `0`), which would otherwise expose private activities at their permalink.
440 *
441 * Users authorized to act as the blog actor are treated as the author of
442 * blog-actor items so they can read the same private outbox they can post to.
443 */
444 if ( \is_user_logged_in() ) {
445 $author = (int) $outbox_item->post_author;
446
447 if ( \get_current_user_id() === $author ) {
448 return self::get_activity( $outbox_item );
449 }
450
451 if ( Actors::BLOG_USER_ID === $author && user_can_act_as_blog() ) {
452 return self::get_activity( $outbox_item );
453 }
454 }
455
456 // Check if Outbox Activity is public.
457 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
458
459 if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
460 return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
461 }
462
463 $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES );
464 $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
465
466 if ( ! in_array( $activity_type, $activity_types, true ) ) {
467 return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
468 }
469
470 return self::get_activity( $outbox_item );
471 }
472
473 /**
474 * Get the object ID of an activity.
475 *
476 * @param Activity|Base_Object|string $data The activity object.
477 *
478 * @return string|null The object ID.
479 */
480 private static function get_object_id( $data ) {
481 $object = $data->get_object();
482
483 if ( is_object( $object ) ) {
484 return self::get_object_id( $object );
485 }
486
487 if ( is_string( $object ) ) {
488 return $object;
489 }
490
491 if ( $data->get_id() ) {
492 return $data->get_id();
493 }
494
495 return object_to_uri( $data->get_actor() );
496 }
497
498 /**
499 * Get the title of an activity recursively.
500 *
501 * @param Activity|Base_Object $activity_object The activity object.
502 *
503 * @return string The title.
504 */
505 private static function get_object_title( $activity_object ) {
506 if ( ! $activity_object ) {
507 return '';
508 }
509
510 if ( is_string( $activity_object ) ) {
511 $post_id = url_to_postid( $activity_object );
512
513 return $post_id ? get_the_title( $post_id ) : '';
514 }
515
516 $title = $activity_object->get_name() ?: $activity_object->get_content();
517
518 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
519 $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
520 }
521
522 return $title;
523 }
524
525 /**
526 * Purge old outbox items.
527 *
528 * Deletes outbox items older than the specified number of days,
529 * except for Follow activities which are always preserved.
530 * Also enforces a hard cap on total items via MAX_ITEMS.
531 *
532 * @param int $days Number of days to keep items. Items older than this will be deleted.
533 *
534 * @return int The number of items deleted.
535 */
536 public static function purge( $days ) {
537 if ( $days <= 0 ) {
538 return 0;
539 }
540
541 $counts = \wp_count_posts( self::POST_TYPE );
542 $total = 0;
543 foreach ( $counts as $count ) {
544 $total += (int) $count;
545 }
546
547 if ( $total <= 20 ) {
548 return 0;
549 }
550
551 $deleted = 0;
552 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
553 $start_time = \time();
554
555 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
556 $overflow = $total > self::MAX_ITEMS;
557 $date_query = array(
558 array(
559 'before' => $cutoff,
560 ),
561 );
562
563 $query_args = array(
564 'post_type' => self::POST_TYPE,
565 'post_status' => 'any',
566 'fields' => 'ids',
567 'numberposts' => self::PURGE_BATCH_SIZE,
568 'orderby' => 'date',
569 'order' => 'ASC',
570 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
571 'meta_query' => array(
572 array(
573 'key' => '_activitypub_activity_type',
574 'value' => 'Follow',
575 'compare' => '!=',
576 ),
577 ),
578 );
579
580 if ( ! $overflow ) {
581 $query_args['date_query'] = $date_query;
582 }
583
584 do {
585 $post_ids = \get_posts( $query_args );
586
587 foreach ( $post_ids as $post_id ) {
588 \wp_delete_post( $post_id, true );
589 ++$deleted;
590 }
591
592 // Once we're back under the cap, re-apply the date filter.
593 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
594 $overflow = false;
595 $query_args['date_query'] = $date_query;
596 }
597 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
598
599 return $deleted;
600 }
601 }
602