PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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.2.1, at includes/collection/class-outbox.php

585 lines 16.3 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
18 /**
19 * ActivityPub Outbox Collection
20 *
21 * @link https://www.w3.org/TR/activitypub/#outbox
22 */
23 class Outbox {
24 /**
25 * The post type for the objects.
26 *
27 * @var string
28 */
29 const POST_TYPE = 'ap_outbox';
30
31 /**
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 * Add an Item to the outbox.
65 *
66 * @param Activity $activity Full Activity object that will be added to the outbox.
67 * @param int $user_id The real or imaginary user ID of the actor that published the activity that will be added to the outbox.
68 * @param string $visibility Optional. The visibility of the content. Default: `ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC`. See `constants.php` for possible values: `ACTIVITYPUB_CONTENT_VISIBILITY_*`.
69 *
70 * @return false|int|\WP_Error The added item or an error.
71 */
72 public static function add( Activity $activity, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
73 $actor_type = Actors::get_type_by_id( $user_id );
74
75 if ( ! $activity->get_actor() ) {
76 $activity->set_actor( Actors::get_by_id( $user_id )->get_id() );
77 }
78
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 $outbox_item = array(
102 'post_type' => self::POST_TYPE,
103 'post_title' => sprintf(
104 /* translators: 1. Activity type, 2. Object Title or Excerpt */
105 __( '[%1$s] %2$s', 'activitypub' ),
106 $activity->get_type(),
107 \wp_trim_words( $title, 5 )
108 ),
109 // Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`.
110 'post_content' => wp_slash( $activity->to_json( true, true ) ),
111 // ensure that user ID is not below 0.
112 'post_author' => \max( $user_id, 0 ),
113 'post_status' => 'pending',
114 'meta_input' => array(
115 '_activitypub_object_id' => $object_id,
116 '_activitypub_activity_type' => $activity->get_type(),
117 '_activitypub_activity_actor' => $actor_type,
118 'activitypub_content_visibility' => $visibility,
119 ),
120 );
121
122 \remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
123
124 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
125 if ( $has_kses ) {
126 // Prevent KSES from corrupting JSON in post_content.
127 \kses_remove_filters();
128 }
129
130 $id = \wp_insert_post( $outbox_item, true );
131
132 // Update the activity ID if the post was inserted successfully.
133 if ( $id && ! \is_wp_error( $id ) ) {
134 $activity->set_id( \get_the_guid( $id ) );
135
136 \wp_update_post(
137 array(
138 'ID' => $id,
139 'post_content' => \wp_slash( $activity->to_json( true, true ) ),
140 )
141 );
142 }
143
144 if ( $has_kses ) {
145 \kses_init_filters();
146 }
147
148 if ( \is_wp_error( $id ) ) {
149 return $id;
150 }
151
152 if ( ! $id ) {
153 return false;
154 }
155
156 self::delete_superseded_items( $object_id, $activity->get_type(), $id );
157
158 return $id;
159 }
160
161 /**
162 * Delete pending outbox items that have been superseded by a newer item.
163 *
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.
167 *
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 * @return void
177 */
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 ) ) {
188 return;
189 }
190
191 $meta_query = array(
192 array(
193 'key' => '_activitypub_object_id',
194 'value' => $object_id,
195 ),
196 );
197
198 // For non-Delete activities, only delete items of the same type.
199 // Delete activities supersede all pending items for the same object.
200 if ( 'Delete' !== $activity_type ) {
201 $meta_query[] = array(
202 'key' => '_activitypub_activity_type',
203 'value' => $activity_type,
204 );
205 }
206
207 $existing_items = get_posts(
208 array(
209 'post_type' => self::POST_TYPE,
210 'post_status' => 'pending',
211 'exclude' => array( $exclude_id ),
212 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
213 'meta_query' => $meta_query,
214 'fields' => 'ids',
215 )
216 );
217
218 foreach ( $existing_items as $existing_item_id ) {
219 Scheduler::unschedule_events_for_item( $existing_item_id );
220 \wp_delete_post( $existing_item_id, true );
221 }
222 }
223
224 /**
225 * Creates an Undo activity.
226 *
227 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
228 *
229 * @return int|bool|\WP_Error The ID of the outbox item or false on failure.
230 */
231 public static function undo( $outbox_item ) {
232 $outbox_item = \get_post( $outbox_item );
233 $activity = self::get_activity( $outbox_item );
234
235 if ( \is_wp_error( $activity ) ) {
236 return $activity;
237 }
238
239 $type = 'Undo';
240 if ( 'Create' === $activity->get_type() ) {
241 $type = 'Delete';
242 } elseif ( 'Add' === $activity->get_type() ) {
243 $type = 'Remove';
244 }
245
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 );
249 }
250
251 /**
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 * Reschedule an activity.
313 *
314 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
315 *
316 * @return bool True if the activity was rescheduled, false otherwise.
317 */
318 public static function reschedule( $outbox_item ) {
319 $outbox_item = get_post( $outbox_item );
320
321 $outbox_item->post_status = 'pending';
322 $outbox_item->post_date = current_time( 'mysql' );
323
324 wp_update_post( $outbox_item );
325
326 Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID );
327
328 return true;
329 }
330
331 /**
332 * Get the Activity object from the Outbox item.
333 *
334 * @param int|\WP_Post $outbox_item The Outbox post or post ID.
335 * @return Activity|\WP_Error The Activity object or WP_Error.
336 */
337 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 );
346 }
347
348 $activity_object = \json_decode( $outbox_item->post_content, true );
349 $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
350
351 if ( $activity_object['type'] === $type ) {
352 $activity = Activity::init_from_array( $activity_object );
353 if ( ! $activity->get_actor() ) {
354 $actor = self::get_actor( $outbox_item );
355 if ( \is_wp_error( $actor ) ) {
356 return $actor;
357 }
358 $activity->set_actor( $actor->get_id() );
359 }
360 } else {
361 $actor = self::get_actor( $outbox_item );
362 if ( \is_wp_error( $actor ) ) {
363 return $actor;
364 }
365
366 $activity = new Activity();
367 $activity->set_type( $type );
368 $activity->set_id( $outbox_item->guid );
369 $activity->set_actor( $actor->get_id() );
370 // Pre-fill the Activity with data (for example cc and to).
371 $activity->set_object( $activity_object );
372 }
373
374 if ( 'Update' === $type ) {
375 $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) );
376 }
377
378 /**
379 * Filters the Activity object before it is returned.
380 *
381 * @param Activity $activity The Activity object.
382 * @param \WP_Post $outbox_item The outbox item post object.
383 */
384 return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item );
385 }
386
387 /**
388 * Get the Actor object from the Outbox item.
389 *
390 * @param \WP_Post $outbox_item The Outbox post.
391 *
392 * @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
393 */
394 public static function get_actor( $outbox_item ) {
395 $actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
396
397 switch ( $actor_type ) {
398 case 'blog':
399 $actor_id = Actors::BLOG_USER_ID;
400 break;
401 case 'application':
402 $actor_id = Actors::APPLICATION_USER_ID;
403 break;
404 case 'user':
405 default:
406 $actor_id = $outbox_item->post_author;
407 break;
408 }
409
410 return Actors::get_by_id( $actor_id );
411 }
412
413 /**
414 * Get the Activity object from the Outbox item.
415 *
416 * @param \WP_Post $outbox_item The Outbox post.
417 *
418 * @return Activity|\WP_Error The Activity object or WP_Error.
419 */
420 public static function maybe_get_activity( $outbox_item ) {
421 if ( ! $outbox_item instanceof \WP_Post ) {
422 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
423 }
424
425 if ( 'ap_outbox' !== $outbox_item->post_type ) {
426 return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' );
427 }
428
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 // Check if Outbox Activity is public.
440 $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true );
441
442 if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) {
443 return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' );
444 }
445
446 $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES );
447 $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
448
449 if ( ! in_array( $activity_type, $activity_types, true ) ) {
450 return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' );
451 }
452
453 return self::get_activity( $outbox_item );
454 }
455
456 /**
457 * Get the object ID of an activity.
458 *
459 * @param Activity|Base_Object|string $data The activity object.
460 *
461 * @return string|null The object ID.
462 */
463 private static function get_object_id( $data ) {
464 $object = $data->get_object();
465
466 if ( is_object( $object ) ) {
467 return self::get_object_id( $object );
468 }
469
470 if ( is_string( $object ) ) {
471 return $object;
472 }
473
474 if ( $data->get_id() ) {
475 return $data->get_id();
476 }
477
478 return object_to_uri( $data->get_actor() );
479 }
480
481 /**
482 * Get the title of an activity recursively.
483 *
484 * @param Activity|Base_Object $activity_object The activity object.
485 *
486 * @return string The title.
487 */
488 private static function get_object_title( $activity_object ) {
489 if ( ! $activity_object ) {
490 return '';
491 }
492
493 if ( is_string( $activity_object ) ) {
494 $post_id = url_to_postid( $activity_object );
495
496 return $post_id ? get_the_title( $post_id ) : '';
497 }
498
499 $title = $activity_object->get_name() ?: $activity_object->get_content();
500
501 if ( ! $title && $activity_object->get_object() instanceof Base_Object ) {
502 $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content();
503 }
504
505 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 }
584 }
585