PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.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 / class-dispatcher.php

class-dispatcher.php in ActivityPub 9.2.2, at includes/class-dispatcher.php

579 lines 18.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ActivityPub Dispatcher Class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Collection\Followers;
12 use Activitypub\Collection\Outbox;
13
14 /**
15 * ActivityPub Dispatcher Class.
16 *
17 * @author Matthias Pfefferle
18 *
19 * @see https://www.w3.org/TR/activitypub/
20 */
21 class Dispatcher {
22 /**
23 * Batch size.
24 *
25 * @deprecated 7.6.0 Use {@see Dispatcher::get_batch_size()}.
26 *
27 * @var int
28 */
29 public static $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE;
30
31 /**
32 * Initialize the class, registering WordPress hooks.
33 */
34 public static function init() {
35 \add_action( 'activitypub_process_outbox', array( self::class, 'process_outbox' ) );
36
37 \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'fire_outbox_handlers' ), 5, 2 );
38 \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_immediate_accept' ), 10, 2 );
39
40 // Default filters to add Inboxes to sent to.
41 \add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_by_mentioned_actors' ), 10, 3 );
42 \add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_replied_urls' ), 10, 3 );
43 \add_filter( 'activitypub_additional_inboxes', array( self::class, 'add_inboxes_of_relays' ), 10, 3 );
44
45 Scheduler::register_async_batch_callback( 'activitypub_send_activity', array( self::class, 'send_to_followers' ) );
46 Scheduler::register_async_batch_callback( 'activitypub_retry_activity', array( self::class, 'retry_send_to_followers' ) );
47 }
48
49 /**
50 * Get the batch size for processing outbox items.
51 *
52 * @return int The batch size.
53 */
54 public static function get_batch_size() {
55 /**
56 * Filters the batch size for processing outbox items.
57 *
58 * @param int $batch_size The batch size. Default ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE.
59 */
60 return \apply_filters( 'activitypub_dispatcher_batch_size', ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE );
61 }
62
63 /**
64 * Get the maximum number of retry attempts.
65 *
66 * @return int The maximum number of retry attempts.
67 */
68 public static function get_retry_max_attempts() {
69 /**
70 * Filters the maximum number of retry attempts.
71 *
72 * @param int $retry_max_attempts The maximum number of retry attempts. Default ACTIVITYPUB_OUTBOX_RETRY_MAX_ATTEMPTS.
73 */
74 return \apply_filters( 'activitypub_dispatcher_retry_max_attempts', 3 );
75 }
76
77 /**
78 * Get the retry delay unit (in seconds).
79 *
80 * Used to calculate exponential backoff: time() + (attempt * attempt * retry_delay_unit).
81 *
82 * @return int The retry delay unit in seconds.
83 */
84 public static function get_retry_delay() {
85 /**
86 * Filters the retry delay unit (in seconds).
87 *
88 * Used to calculate exponential backoff: time() + (attempt * attempt * retry_delay_unit).
89 *
90 * @param int $retry_delay_unit The retry delay unit in seconds. Default ACTIVITYPUB_OUTBOX_RETRY_DELAY_UNIT.
91 */
92 return \apply_filters( 'activitypub_dispatcher_retry_delay', HOUR_IN_SECONDS );
93 }
94
95 /**
96 * Get the error codes that qualify for a retry.
97 *
98 * @see https://github.com/tfredrich/RestApiTutorial.com/blob/fd08b0f67f07450521d143b123cd6e1846cb2e3b/content/advanced/responses/retries.md
99 *
100 * @return int[] The error codes.
101 */
102 public static function get_retry_error_codes() {
103 /**
104 * Filters the error codes that qualify for a retry.
105 *
106 * @param int[] $retry_error_codes The error codes. Default array( 408, 429, 500, 502, 503, 504 ).
107 */
108 return \apply_filters( 'activitypub_dispatcher_retry_error_codes', ACTIVITYPUB_RETRY_ERROR_CODES );
109 }
110
111 /**
112 * Process the outbox.
113 *
114 * @param int $id The outbox ID.
115 */
116 public static function process_outbox( $id ) {
117 $outbox_item = \get_post( $id );
118
119 // If the activity is not a post, return.
120 if ( ! $outbox_item ) {
121 return;
122 }
123
124 $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
125 $actor = Outbox::get_actor( $outbox_item );
126 if ( \is_wp_error( $actor ) && 'Delete' !== $type ) {
127 // If the actor is not found, publish the post and don't try again.
128 \wp_publish_post( $outbox_item );
129 return;
130 }
131
132 $activity = Outbox::get_activity( $outbox_item );
133
134 // Send to mentioned and replied-to users. Everyone other than followers.
135 self::send_to_additional_inboxes( $activity, $outbox_item->post_author, $outbox_item );
136
137 if ( self::should_send_to_followers( $activity, $actor, $outbox_item ) ) {
138 \do_action(
139 'activitypub_send_activity',
140 $outbox_item->ID,
141 self::get_batch_size(),
142 \get_post_meta( $outbox_item->ID, '_activitypub_outbox_offset', true ) ?: 0 // phpcs:ignore
143 );
144 } else {
145 // No followers to process for this update. We're done.
146 \wp_publish_post( $outbox_item );
147 \delete_post_meta( $outbox_item->ID, '_activitypub_outbox_offset' );
148 }
149 }
150
151 /**
152 * Asynchronously runs batch processing routines.
153 *
154 * @param int $outbox_item_id The Outbox item ID.
155 * @param int|null $batch_size Optional. The batch size. Default null (uses filtered batch size).
156 * @param int $offset Optional. The offset. Default 0.
157 *
158 * @return array|void The next batch of followers to process, or void if done.
159 */
160 public static function send_to_followers( $outbox_item_id, $batch_size = ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE, $offset = 0 ) {
161 if ( null === $batch_size ) {
162 $batch_size = self::get_batch_size();
163 }
164
165 $outbox_item = \get_post( $outbox_item_id );
166
167 if ( ! $outbox_item ) {
168 return;
169 }
170
171 $activity = Outbox::get_activity( $outbox_item_id );
172
173 if ( \is_wp_error( $activity ) ) {
174 return;
175 }
176
177 $json = $activity->to_json();
178 $inboxes = Followers::get_inboxes_for_activity( $json, $outbox_item->post_author, $batch_size, $offset );
179 $retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
180
181 // Retry failed inboxes.
182 if ( ! empty( $retries ) ) {
183 self::schedule_retry( $retries, $outbox_item_id );
184 }
185
186 if ( \is_countable( $inboxes ) && \count( $inboxes ) < $batch_size ) {
187 \delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
188
189 /**
190 * Fires when the followers are complete.
191 *
192 * @param array $inboxes The inboxes.
193 * @param string $json The ActivityPub Activity JSON
194 * @param int $actor_id The actor ID.
195 * @param int $outbox_item_id The Outbox item ID.
196 * @param int $batch_size The batch size.
197 * @param int $offset The offset.
198 */
199 \do_action( 'activitypub_outbox_processing_complete', $inboxes, $json, $outbox_item->post_author, $outbox_item_id, $batch_size, $offset );
200
201 // No more followers to process for this update.
202 \wp_publish_post( $outbox_item_id );
203 } else {
204 \update_post_meta( $outbox_item_id, '_activitypub_outbox_offset', $offset + $batch_size );
205
206 /**
207 * Fires when the batch of followers is complete.
208 *
209 * @param array $inboxes The inboxes.
210 * @param string $json The ActivityPub Activity JSON
211 * @param int $actor_id The actor ID.
212 * @param int $outbox_item_id The Outbox item ID.
213 * @param int $batch_size The batch size.
214 * @param int $offset The offset.
215 */
216 \do_action( 'activitypub_outbox_processing_batch_complete', $inboxes, $json, $outbox_item->post_author, $outbox_item_id, $batch_size, $offset );
217
218 return array( $outbox_item_id, $batch_size, $offset + $batch_size );
219 }
220 }
221
222 /**
223 * Retry sending to followers.
224 *
225 * @param string $transient_key The key to retrieve retry inboxes.
226 * @param int $outbox_item_id The Outbox item ID.
227 * @param int $attempt The attempt number.
228 */
229 public static function retry_send_to_followers( $transient_key, $outbox_item_id, $attempt = 1 ) {
230 $inboxes = \get_transient( $transient_key );
231 if ( false === $inboxes ) {
232 return;
233 }
234
235 // Delete the transient as we no longer need it.
236 \delete_transient( $transient_key );
237
238 $retries = self::send_to_inboxes( $inboxes, $outbox_item_id );
239
240 // Retry failed inboxes.
241 if ( ++$attempt < self::get_retry_max_attempts() && ! empty( $retries ) ) {
242 self::schedule_retry( $retries, $outbox_item_id, $attempt );
243 }
244 }
245
246 /**
247 * Send to inboxes.
248 *
249 * @param array $inboxes The inboxes to notify.
250 * @param int $outbox_item_id The Outbox item ID.
251 * @return array The failed inboxes.
252 */
253 private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
254 $outbox_item = \get_post( $outbox_item_id );
255
256 $activity = Outbox::get_activity( $outbox_item_id );
257
258 if ( \is_wp_error( $activity ) ) {
259 return array();
260 }
261
262 $json = $activity->to_json();
263
264 $retries = array();
265
266 /**
267 * Fires before sending an Activity to inboxes.
268 *
269 * @param string $json The ActivityPub Activity JSON.
270 * @param array $inboxes The inboxes to send to.
271 * @param int $outbox_item_id The Outbox item ID.
272 */
273 \do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );
274
275 foreach ( $inboxes as $inbox ) {
276 // Handle local inboxes via internal REST API, remote via HTTP.
277 if ( is_same_domain( $inbox ) ) {
278 $result = self::send_to_local_inbox( $inbox, $json );
279 } else {
280 $result = safe_remote_post( $inbox, $json, $outbox_item->post_author );
281 }
282
283 if ( \is_wp_error( $result ) && \in_array( $result->get_error_code(), self::get_retry_error_codes(), true ) ) {
284 $retries[] = $inbox;
285 }
286
287 /**
288 * Fires after an Activity has been sent to an inbox.
289 *
290 * @param array $result The result of the internal or remote post request.
291 * @param string $inbox The inbox URL.
292 * @param string $json The ActivityPub Activity JSON.
293 * @param int $actor_id The actor ID.
294 * @param int $outbox_item_id The Outbox item ID.
295 */
296 \do_action( 'activitypub_sent_to_inbox', $result, $inbox, $json, $outbox_item->post_author, $outbox_item_id );
297 }
298
299 return $retries;
300 }
301
302 /**
303 * Send an activity to a local inbox via internal REST API request.
304 *
305 * @param string $inbox_url The local inbox URL.
306 * @param string $json The ActivityPub Activity JSON.
307 * @return array|\WP_Error The result in the format of a remote post response, or WP_Error on failure.
308 */
309 private static function send_to_local_inbox( $inbox_url, $json ) {
310 // Parse the inbox URL to extract the REST route.
311 $path = \wp_parse_url( $inbox_url, PHP_URL_PATH ) ?? '';
312 $rest_route = \preg_replace( '#^/' . \preg_quote( \rest_get_url_prefix(), '#' ) . '#', '', $path );
313
314 // Create a REST request.
315 $request = new \WP_REST_Request( 'POST', $rest_route );
316 $request->set_header( 'Content-Type', 'application/activity+json' );
317 $request->set_body( $json );
318 $request->get_json_params();
319
320 \add_filter( 'activitypub_defer_signature_verification', '__return_true' );
321 $response = \rest_do_request( $request );
322 \remove_filter( 'activitypub_defer_signature_verification', '__return_true' );
323
324 // Return result in format similar to remote post response.
325 if ( $response->is_error() ) {
326 return $response->as_error();
327 }
328
329 return array(
330 'response' => array(
331 'code' => $response->get_status(),
332 ),
333 'body' => \wp_json_encode( $response->get_data() ),
334 );
335 }
336
337 /**
338 * Schedule a retry.
339 *
340 * @param array $retries The inboxes to retry.
341 * @param int $outbox_item_id The Outbox item ID.
342 * @param int $attempt Optional. The attempt number. Default 1.
343 */
344 private static function schedule_retry( $retries, $outbox_item_id, $attempt = 1 ) {
345 $transient_key = 'activitypub_retry_' . \wp_generate_password( 12, false );
346 \set_transient( $transient_key, $retries, WEEK_IN_SECONDS );
347
348 \wp_schedule_single_event(
349 \time() + ( $attempt * $attempt * self::get_retry_delay() ),
350 'activitypub_retry_activity',
351 array( $transient_key, $outbox_item_id, $attempt )
352 );
353 }
354
355 /**
356 * Send an Activity to a custom list of inboxes, like mentioned users or replied-to posts.
357 *
358 * For all custom implementations, please use the `activitypub_additional_inboxes` filter.
359 *
360 * @param Activity $activity The ActivityPub Activity.
361 * @param int $actor_id The actor ID.
362 * @param \WP_Post $outbox_item The WordPress object.
363 */
364 private static function send_to_additional_inboxes( $activity, $actor_id, $outbox_item = null ) {
365 /**
366 * Filters the list of inboxes to send the Activity to.
367 *
368 * @param array $inboxes The list of inboxes to send to.
369 * @param int $actor_id The actor ID.
370 * @param Activity $activity The ActivityPub Activity.
371 */
372 $inboxes = \apply_filters( 'activitypub_additional_inboxes', array(), $actor_id, $activity );
373 $inboxes = \array_unique( $inboxes );
374
375 $retries = self::send_to_inboxes( $inboxes, $outbox_item->ID );
376
377 // Retry failed inboxes.
378 if ( ! empty( $retries ) ) {
379 self::schedule_retry( $retries, $outbox_item->ID );
380 }
381 }
382
383 /**
384 * Default filter to add Inboxes of Mentioned Actors
385 *
386 * @param array $inboxes The list of Inboxes.
387 * @param int $actor_id The WordPress Actor-ID.
388 * @param Activity $activity The ActivityPub Activity.
389 *
390 * @return array The filtered Inboxes.
391 */
392 public static function add_inboxes_by_mentioned_actors( $inboxes, $actor_id, $activity ) {
393 $cc = $activity->get_cc() ?? array();
394 $to = $activity->get_to() ?? array();
395
396 $audience = \array_merge( $cc, $to );
397
398 // Remove "public placeholder" from the audience.
399 $audience = \array_diff( $audience, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS );
400
401 if ( $audience ) {
402 $mentioned_inboxes = Mention::get_inboxes( $audience );
403
404 return \array_merge( $inboxes, $mentioned_inboxes );
405 }
406
407 return $inboxes;
408 }
409
410 /**
411 * Default filter to add Inboxes of Posts that are set as `in-reply-to`
412 *
413 * @param array $inboxes The list of Inboxes.
414 * @param int $actor_id The WordPress Actor-ID.
415 * @param Activity $activity The ActivityPub Activity.
416 *
417 * @return array The filtered Inboxes
418 */
419 public static function add_inboxes_of_replied_urls( $inboxes, $actor_id, $activity ) {
420 $in_reply_to = $activity->get_in_reply_to();
421
422 if ( ! $in_reply_to ) {
423 return $inboxes;
424 }
425
426 if ( ! \is_array( $in_reply_to ) ) {
427 $in_reply_to = array( $in_reply_to );
428 }
429
430 foreach ( $in_reply_to as $url ) {
431 // No need to self-notify.
432 if ( is_same_domain( $url ) ) {
433 continue;
434 }
435
436 $object = Http::get_remote_object( $url );
437
438 if (
439 ! $object ||
440 \is_wp_error( $object ) ||
441 empty( $object['attributedTo'] )
442 ) {
443 continue;
444 }
445
446 $actor = object_to_uri( $object['attributedTo'] );
447 $actor = Http::get_remote_object( $actor );
448
449 if ( ! $actor || \is_wp_error( $actor ) ) {
450 continue;
451 }
452
453 if ( ! empty( $actor['endpoints']['sharedInbox'] ) ) {
454 $inboxes[] = $actor['endpoints']['sharedInbox'];
455 } elseif ( ! empty( $actor['inbox'] ) ) {
456 $inboxes[] = $actor['inbox'];
457 }
458 }
459
460 return $inboxes;
461 }
462
463 /**
464 * Check if an Activity should be sent to followers.
465 *
466 * @param Activity $activity The Activity object.
467 * @param \Activitypub\Model\User|\Activitypub\Model\Blog $actor The Actor object.
468 * @param \WP_Post $outbox_item The Outbox item.
469 *
470 * @return boolean True if the Activity should be sent to followers, false if not.
471 */
472 protected static function should_send_to_followers( $activity, $actor, $outbox_item ) {
473 $cc = (array) ( $activity->get_cc() ?? array() );
474 $to = (array) ( $activity->get_to() ?? array() );
475 $bcc = (array) ( $activity->get_bcc() ?? array() );
476 $bto = (array) ( $activity->get_bto() ?? array() );
477
478 $audience = \array_merge( $cc, $to, $bcc, $bto );
479
480 $send = (
481 // Check if activity is public.
482 is_activity_public( $activity ) ||
483 // ...or check if follower endpoint is set.
484 \in_array( $actor->get_followers(), $audience, true )
485 );
486
487 if ( $send ) {
488 $followers = Followers::get_inboxes_for_activity( $activity->to_json(), $outbox_item->post_author );
489
490 // Only send if there are followers to send to.
491 $send = ! \is_countable( $followers ) || 0 < \count( $followers );
492 }
493
494 /**
495 * Filters whether to send an Activity to followers.
496 *
497 * @param bool $send_activity_to_followers Whether to send the Activity to followers.
498 * @param Activity $activity The ActivityPub Activity.
499 * @param int $actor_id The actor ID.
500 * @param \WP_Post $outbox_item The WordPress object.
501 */
502 return \apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $outbox_item->post_author, $outbox_item );
503 }
504
505 /**
506 * Add Inboxes of Relays.
507 *
508 * @param array $inboxes The list of Inboxes.
509 * @param int $actor_id The Actor-ID.
510 * @param Activity $activity The ActivityPub Activity.
511 *
512 * @return array The filtered Inboxes.
513 */
514 public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) {
515 // Check if activity is public.
516 if ( ! is_activity_public( $activity ) ) {
517 return $inboxes;
518 }
519
520 $relays = \get_option( 'activitypub_relays', array() );
521
522 if ( empty( $relays ) ) {
523 return $inboxes;
524 }
525
526 return \array_merge( $inboxes, $relays );
527 }
528
529 /**
530 * Fire outbox handlers for activities.
531 *
532 * Triggers activity type-specific handlers to process outbox activities,
533 * allowing handlers to create WordPress posts or perform other side effects.
534 *
535 * @param int $outbox_id The Outbox item ID.
536 * @param Activity $activity The Activity that was just added to the Outbox.
537 */
538 public static function fire_outbox_handlers( $outbox_id, $activity ) {
539 $outbox_item = \get_post( $outbox_id );
540
541 if ( ! $outbox_item ) {
542 return;
543 }
544
545 $type = $activity->get_type();
546 $user_id = $outbox_item->post_author;
547 $data = $activity->to_array( false );
548
549 /**
550 * Fires when an activity has been added to the outbox.
551 *
552 * Handlers can implement side effects like creating WordPress posts.
553 *
554 * @param array $data The activity data array.
555 * @param int $user_id The user ID.
556 * @param Activity $activity The Activity object.
557 * @param int $outbox_id The outbox post ID.
558 */
559 \do_action( 'activitypub_handled_outbox_' . \strtolower( $type ), $data, $user_id, $activity, $outbox_id );
560 }
561
562 /**
563 * Send an immediate Accept activity for the given Outbox item.
564 *
565 * @param int $outbox_id The Outbox item ID.
566 * @param Activity $activity The Activity that was just added to the Outbox.
567 */
568 public static function send_immediate_accept( $outbox_id, $activity ) {
569 $outbox_item = \get_post( $outbox_id );
570
571 if ( ! $outbox_item || 'Accept' !== $activity->get_type() ) {
572 return;
573 }
574
575 // Send to mentioned and replied-to users. Everyone other than followers.
576 self::send_to_additional_inboxes( $activity, $outbox_item->post_author, $outbox_item );
577 }
578 }
579