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

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