PluginProbe
ActivityPub / 8.2.0
ActivityPub v8.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 / class-scheduler.php

class-scheduler.php in ActivityPub 8.2.0, at includes/class-scheduler.php

640 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Scheduler class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Activity\Base_Object;
12 use Activitypub\Collection\Actors;
13 use Activitypub\Collection\Inbox;
14 use Activitypub\Collection\Outbox;
15 use Activitypub\Collection\Remote_Actors;
16 use Activitypub\Collection\Remote_Posts;
17 use Activitypub\Scheduler\Actor;
18 use Activitypub\Scheduler\Collection_Sync;
19 use Activitypub\Scheduler\Comment;
20 use Activitypub\Scheduler\Post;
21 use Activitypub\Scheduler\Statistics;
22
23 /**
24 * Scheduler class.
25 *
26 * @author Matthias Pfefferle
27 */
28 class Scheduler {
29
30 /**
31 * Scheduled events with their recurrence.
32 *
33 * @var array
34 */
35 const SCHEDULES = array(
36 'activitypub_update_remote_actors' => 'hourly',
37 'activitypub_cleanup_remote_actors' => 'daily',
38 'activitypub_reprocess_outbox' => 'hourly',
39 'activitypub_outbox_purge' => 'daily',
40 'activitypub_inbox_purge' => 'daily',
41 'activitypub_ap_post_purge' => 'daily',
42 'activitypub_sync_blocklist_subscriptions' => 'weekly',
43 );
44
45 /**
46 * Allowed batch callbacks.
47 *
48 * @var array
49 */
50 private static $batch_callbacks = array();
51
52 /**
53 * Get the pause between async batches (in seconds).
54 *
55 * @return int The pause in seconds.
56 */
57 public static function get_retry_delay() {
58 /**
59 * Filters the pause between async batches (in seconds).
60 *
61 * @param int $async_batch_pause The pause in seconds. Default 30.
62 */
63 return apply_filters( 'activitypub_scheduler_async_batch_pause', 30 );
64 }
65
66 /**
67 * Initialize the class, registering WordPress hooks.
68 */
69 public static function init() {
70 self::register_schedulers();
71
72 // Custom cron schedules.
73 \add_filter( 'cron_schedules', array( self::class, 'add_cron_schedules' ) );
74
75 // Follower Cleanups.
76 \add_action( 'activitypub_update_remote_actors', array( self::class, 'update_remote_actors' ) );
77 \add_action( 'activitypub_cleanup_remote_actors', array( self::class, 'cleanup_remote_actors' ) );
78
79 // Event callbacks.
80 \add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 );
81 \add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) );
82 \add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) );
83 \add_action( 'activitypub_inbox_purge', array( self::class, 'purge_inbox' ) );
84 \add_action( 'activitypub_ap_post_purge', array( self::class, 'purge_ap_posts' ) );
85 \add_action( 'activitypub_inbox_create_item', array( self::class, 'process_inbox_activity' ) );
86 \add_action( 'activitypub_sync_blocklist_subscriptions', array( Blocklist_Subscriptions::class, 'sync_all' ) );
87
88 \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) );
89 \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 );
90
91 \add_action( 'update_option_activitypub_outbox_purge_days', array( self::class, 'update_outbox_purge_schedule' ), 10, 2 );
92 \add_action( 'update_option_activitypub_inbox_purge_days', array( self::class, 'update_inbox_purge_schedule' ), 10, 2 );
93 \add_action( 'update_option_activitypub_ap_post_purge_days', array( self::class, 'update_ap_post_purge_schedule' ), 10, 2 );
94 }
95
96 /**
97 * Register handlers.
98 */
99 public static function register_schedulers() {
100 Post::init();
101 Actor::init();
102 Collection_Sync::init();
103 Comment::init();
104 Statistics::init();
105
106 /**
107 * Register additional schedulers.
108 *
109 * @since 5.0.0
110 */
111 \do_action( 'activitypub_register_schedulers' );
112 }
113
114 /**
115 * Add custom cron schedules.
116 *
117 * @param array $schedules Existing cron schedules.
118 *
119 * @return array Modified cron schedules.
120 */
121 public static function add_cron_schedules( $schedules ) {
122 $schedules['monthly'] = array(
123 'interval' => MONTH_IN_SECONDS,
124 'display' => \__( 'Once Monthly', 'activitypub' ),
125 );
126
127 $schedules['yearly'] = array(
128 'interval' => YEAR_IN_SECONDS,
129 'display' => \__( 'Once Yearly', 'activitypub' ),
130 );
131
132 return $schedules;
133 }
134
135 /**
136 * Register a batch callback for async processing.
137 *
138 * @param string $hook The cron event hook name.
139 * @param callable $callback The callback to execute.
140 */
141 public static function register_async_batch_callback( $hook, $callback ) {
142 if ( \did_action( 'init' ) && ! \doing_action( 'init' ) ) {
143 \_doing_it_wrong( __METHOD__, 'Async batch callbacks should be registered before or during the init action.', '7.5.0' );
144 return;
145 }
146
147 if ( ! \is_callable( $callback ) ) {
148 return;
149 }
150
151 self::$batch_callbacks[ $hook ] = $callback;
152
153 // Register the WordPress action hook to trigger async_batch.
154 \add_action( $hook, array( self::class, 'async_batch' ), 10, 99 );
155 }
156
157 /**
158 * Schedule all ActivityPub schedules.
159 */
160 public static function register_schedules() {
161 foreach ( self::SCHEDULES as $hook => $recurrence ) {
162 if ( ! \wp_next_scheduled( $hook ) ) {
163 \wp_schedule_event( time(), $recurrence, $hook );
164 }
165 }
166
167 // Schedule monthly stats collection for the 1st of each month.
168 if ( ! \wp_next_scheduled( 'activitypub_collect_monthly_stats' ) ) {
169 // Calculate next 1st of month at 2:00 AM.
170 $next_first = self::get_next_first_of_month();
171 \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' );
172 }
173
174 // Schedule annual stats compilation for December 1st (wrapped notification).
175 if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) {
176 $next_december = self::get_next_december_first();
177 \wp_schedule_event( $next_december, 'yearly', 'activitypub_compile_annual_stats' );
178 }
179 }
180
181 /**
182 * Un-schedule all ActivityPub schedules.
183 *
184 * @return void
185 */
186 public static function deregister_schedules() {
187 foreach ( array_keys( self::SCHEDULES ) as $hook ) {
188 \wp_unschedule_hook( $hook );
189 }
190
191 // Statistics schedules.
192 \wp_unschedule_hook( 'activitypub_collect_monthly_stats' );
193 \wp_unschedule_hook( 'activitypub_compile_annual_stats' );
194 }
195
196 /**
197 * Get the next 1st of month timestamp.
198 *
199 * @return int Unix timestamp of next 1st of month at 2:00 AM.
200 */
201 private static function get_next_first_of_month() {
202 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
203 $next_month = \strtotime( 'first day of next month 02:00:00', $now );
204
205 return $next_month;
206 }
207
208 /**
209 * Get the next December 1st timestamp for wrapped notification.
210 *
211 * @return int Unix timestamp of next December 1st at 3:00 AM.
212 */
213 private static function get_next_december_first() {
214 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
215 $year = (int) \gmdate( 'Y', $now );
216
217 // Get December 1st 3:00 AM for this year.
218 $this_year_dec_first = \strtotime( sprintf( '%d-12-01 03:00:00', $year ) );
219
220 // If we're already past this year's December 1st, schedule for next year.
221 if ( $now >= $this_year_dec_first ) {
222 return \strtotime( sprintf( '%d-12-01 03:00:00', $year + 1 ) );
223 }
224
225 return $this_year_dec_first;
226 }
227
228 /**
229 * Unschedule events for an outbox item.
230 *
231 * @param int $outbox_item_id The outbox item ID.
232 */
233 public static function unschedule_events_for_item( $outbox_item_id ) {
234 $event_args = array(
235 $outbox_item_id,
236 Dispatcher::get_batch_size(),
237 \get_post_meta( $outbox_item_id, '_activitypub_outbox_offset', true ) ?: 0, // phpcs:ignore
238 );
239
240 \delete_post_meta( $outbox_item_id, '_activitypub_outbox_offset' );
241
242 $timestamp = \wp_next_scheduled( 'activitypub_process_outbox', array( $outbox_item_id ) );
243 \wp_unschedule_event( $timestamp, 'activitypub_process_outbox', array( $outbox_item_id ) );
244
245 $timestamp = \wp_next_scheduled( 'activitypub_send_activity', $event_args );
246 \wp_unschedule_event( $timestamp, 'activitypub_send_activity', $event_args );
247
248 // Invalidate any retries for this outbox item.
249 foreach ( _get_cron_array() as $timestamp => $cron ) {
250 if ( ! isset( $cron['activitypub_retry_activity'] ) ) {
251 continue;
252 }
253
254 foreach ( $cron['activitypub_retry_activity'] as $event ) {
255 if ( isset( $event['args'][1] ) && $outbox_item_id === $event['args'][1] ) {
256 \wp_unschedule_event( $timestamp, 'activitypub_retry_activity', $event['args'] );
257 }
258 }
259 }
260 }
261
262 /**
263 * Update remote Actors.
264 */
265 public static function update_remote_actors() {
266 $number = 5;
267
268 if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
269 $number = 50;
270 }
271
272 /**
273 * Filter the number of remote Actors to update.
274 *
275 * @param int $number The number of remote Actors to update.
276 */
277 $number = apply_filters( 'activitypub_update_remote_actors_number', $number );
278 $actors = Remote_Actors::get_outdated( $number );
279
280 foreach ( $actors as $actor ) {
281 $meta = get_remote_metadata_by_actor( $actor->guid, false );
282
283 if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) {
284 Remote_Actors::add_error( $actor->ID, 'Failed to fetch or parse metadata' );
285 } else {
286 $id = Remote_Actors::upsert( $meta );
287 if ( \is_wp_error( $id ) ) {
288 continue;
289 }
290 Remote_Actors::clear_errors( $id );
291 }
292 }
293 }
294
295 /**
296 * Cleanup remote Actors.
297 */
298 public static function cleanup_remote_actors() {
299 $number = 5;
300
301 if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
302 $number = 50;
303 }
304
305 /**
306 * Filter the number of remote Actors to clean up.
307 *
308 * @param int $number The number of remote Actors to clean up.
309 */
310 $number = apply_filters( 'activitypub_cleanup_remote_actors_number', $number );
311 $actors = Remote_Actors::get_faulty( $number );
312
313 foreach ( $actors as $actor ) {
314 $meta = get_remote_metadata_by_actor( $actor->guid, false );
315
316 if ( Tombstone::exists( $meta ) ) {
317 \wp_delete_post( $actor->ID );
318 } elseif ( empty( $meta ) || ! is_array( $meta ) || \is_wp_error( $meta ) ) {
319 if ( Remote_Actors::count_errors( $actor->ID ) >= 5 ) {
320 \wp_schedule_single_event( \time(), 'activitypub_delete_remote_actor_interactions', array( $actor->guid ) );
321 \wp_schedule_single_event( \time(), 'activitypub_delete_remote_actor_posts', array( $actor->guid ) );
322 \wp_delete_post( $actor->ID );
323 } else {
324 Remote_Actors::add_error( $actor->ID, $meta );
325 }
326 } else {
327 $id = Remote_Actors::upsert( $meta );
328 if ( \is_wp_error( $id ) ) {
329 Remote_Actors::add_error( $actor->ID, $id );
330 } else {
331 Remote_Actors::clear_errors( $actor->ID );
332 }
333 }
334 }
335 }
336
337 /**
338 * Schedule the outbox item for federation.
339 *
340 * @param int $id The ID of the outbox item.
341 * @param int $offset The offset to add to the scheduled time. Default 3 seconds.
342 */
343 public static function schedule_outbox_activity_for_federation( $id, $offset = 3 ) {
344 $hook = 'activitypub_process_outbox';
345 $args = array( $id );
346
347 if ( false === wp_next_scheduled( $hook, $args ) ) {
348 \wp_schedule_single_event(
349 \time() + $offset,
350 $hook,
351 $args
352 );
353 }
354 }
355
356 /**
357 * Reprocess the outbox.
358 */
359 public static function reprocess_outbox() {
360 $ids = \get_posts(
361 array(
362 'post_type' => Outbox::POST_TYPE,
363 'post_status' => 'pending',
364 'posts_per_page' => 10,
365 'fields' => 'ids',
366 )
367 );
368
369 foreach ( $ids as $id ) {
370 // Bail if there is a pending batch.
371 $offset = \get_post_meta( $id, '_activitypub_outbox_offset', true ) ?: 0; // phpcs:ignore
372 if ( \wp_next_scheduled( 'activitypub_send_activity', array( $id, Dispatcher::get_batch_size(), $offset ) ) ) {
373 return;
374 }
375
376 // Bail if there is a batch in progress.
377 $key = \md5( \serialize( $id ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
378 if ( self::is_locked( $key ) ) {
379 return;
380 }
381
382 self::schedule_outbox_activity_for_federation( $id );
383 }
384 }
385
386 /**
387 * Purge outbox items based on a schedule.
388 */
389 public static function purge_outbox() {
390 Outbox::purge( \get_option( 'activitypub_outbox_purge_days', ACTIVITYPUB_OUTBOX_PURGE_DAYS ) );
391 }
392
393 /**
394 * Purge inbox items based on a schedule.
395 */
396 public static function purge_inbox() {
397 Inbox::purge( \get_option( 'activitypub_inbox_purge_days', ACTIVITYPUB_INBOX_PURGE_DAYS ) );
398 }
399
400 /**
401 * Purge remote posts based on a schedule.
402 */
403 public static function purge_ap_posts() {
404 Remote_Posts::purge( \get_option( 'activitypub_ap_post_purge_days', ACTIVITYPUB_AP_POST_PURGE_DAYS ) );
405 }
406
407 /**
408 * Process cached inbox activity.
409 *
410 * Retrieves all collected user IDs for an activity and processes them together.
411 *
412 * @param string $activity_id The activity ID.
413 */
414 public static function process_inbox_activity( $activity_id ) {
415 // Deduplicate if multiple inbox items were created due to race condition.
416 $inbox_item = Inbox::deduplicate( $activity_id );
417 if ( ! $inbox_item ) {
418 return;
419 }
420
421 $data = \json_decode( $inbox_item->post_content, true );
422 // Reconstruct activity from inbox post.
423 $activity = Activity::init_from_array( $data );
424 $type = \Activitypub\camel_to_snake_case( $activity->get_type() );
425 $context = Inbox::CONTEXT_INBOX;
426 $user_ids = Inbox::get_recipients( $inbox_item->ID );
427
428 /**
429 * Fires after any ActivityPub Inbox activity has been handled, regardless of activity type.
430 *
431 * This hook is triggered for all activity types processed by the inbox handler.
432 *
433 * @param array $data The data array.
434 * @param array $user_ids The user IDs.
435 * @param string $type The type of the activity.
436 * @param Activity $activity The Activity object.
437 * @param int $result The ID of the inbox item that was created, or WP_Error if failed.
438 * @param string $context The context of the request ('inbox' or 'shared_inbox').
439 */
440 \do_action( 'activitypub_handled_inbox', $data, $user_ids, $type, $activity, $inbox_item->ID, $context );
441
442 /**
443 * Fires after an ActivityPub Inbox activity has been handled.
444 *
445 * @param array $data The data array.
446 * @param array $user_ids The user IDs.
447 * @param Activity $activity The Activity object.
448 * @param int $result The ID of the inbox item that was created, or WP_Error if failed.
449 * @param string $context The context of the request ('inbox' or 'shared_inbox').
450 */
451 \do_action( 'activitypub_handled_inbox_' . $type, $data, $user_ids, $activity, $inbox_item->ID, $context );
452 }
453
454 /**
455 * Update schedules when outbox purge days settings change.
456 *
457 * @param int $old_value The old value.
458 * @param int $value The new value.
459 */
460 public static function update_outbox_purge_schedule( $old_value, $value ) {
461 if ( 0 === (int) $value ) {
462 \wp_clear_scheduled_hook( 'activitypub_outbox_purge' );
463 } elseif ( ! \wp_next_scheduled( 'activitypub_outbox_purge' ) ) {
464 \wp_schedule_event( \time(), 'daily', 'activitypub_outbox_purge' );
465 }
466 }
467
468 /**
469 * Update schedules when inbox purge days settings change.
470 *
471 * @param int $old_value The old value.
472 * @param int $value The new value.
473 */
474 public static function update_inbox_purge_schedule( $old_value, $value ) {
475 if ( 0 === (int) $value ) {
476 \wp_clear_scheduled_hook( 'activitypub_inbox_purge' );
477 } elseif ( ! \wp_next_scheduled( 'activitypub_inbox_purge' ) ) {
478 \wp_schedule_event( \time(), 'daily', 'activitypub_inbox_purge' );
479 }
480 }
481
482 /**
483 * Update schedules when remote posts purge days settings change.
484 *
485 * @param int $old_value The old value.
486 * @param int $value The new value.
487 */
488 public static function update_ap_post_purge_schedule( $old_value, $value ) {
489 if ( 0 === (int) $value ) {
490 \wp_clear_scheduled_hook( 'activitypub_ap_post_purge' );
491 } elseif ( ! \wp_next_scheduled( 'activitypub_ap_post_purge' ) ) {
492 \wp_schedule_event( \time(), 'daily', 'activitypub_ap_post_purge' );
493 }
494 }
495
496 /**
497 * Asynchronously runs batch processing routines.
498 *
499 * The batching part is optional and only comes into play if the callback returns anything.
500 * Beyond that it's a helper to run a callback asynchronously with locking to prevent simultaneous processing.
501 *
502 * @params mixed ...$args Optional. Parameters that get passed to the callback.
503 */
504 public static function async_batch() {
505 $args = \func_get_args(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue
506 $callback = self::$batch_callbacks[ \current_action() ] ?? $args[0] ?? null;
507 if ( ! \is_callable( $callback ) ) {
508 \_doing_it_wrong( __METHOD__, 'There must be a valid callback associated with the current action.', '5.2.0' );
509 return;
510 }
511
512 $key = \md5( \serialize( $callback ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
513
514 // Bail if the existing lock is still valid.
515 if ( self::is_locked( $key ) ) {
516 \wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, \current_action(), $args );
517 return;
518 }
519
520 self::lock( $key );
521
522 if ( \is_callable( $args[0] ?? null ) ) {
523 $callback = \array_shift( $args ); // Remove $callback from arguments.
524 }
525 $next = \call_user_func_array( $callback, $args );
526
527 self::unlock( $key );
528
529 if ( ! empty( $next ) ) {
530 // Schedule the next run, adding the result to the arguments.
531 \wp_schedule_single_event( \time() + self::get_retry_delay(), \current_action(), \array_values( $next ) );
532 }
533 }
534
535 /**
536 * Locks the async batch process for individual callbacks to prevent simultaneous processing.
537 *
538 * @param string $key Serialized callback name.
539 * @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
540 */
541 public static function lock( $key ) {
542 global $wpdb;
543
544 // Try to lock.
545 $lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_async_batch_' . $key, \time() ) ); // phpcs:ignore WordPress.DB
546
547 if ( ! $lock_result ) {
548 $lock_result = \get_option( 'activitypub_async_batch_' . $key );
549 }
550
551 return $lock_result;
552 }
553
554 /**
555 * Unlocks processing for the async batch callback.
556 *
557 * @param string $key Serialized callback name.
558 */
559 public static function unlock( $key ) {
560 \delete_option( 'activitypub_async_batch_' . $key );
561 }
562
563 /**
564 * Whether the async batch callback is locked.
565 *
566 * @param string $key Serialized callback name.
567 * @return boolean
568 */
569 public static function is_locked( $key ) {
570 $lock = \get_option( 'activitypub_async_batch_' . $key );
571
572 if ( ! $lock ) {
573 return false;
574 }
575
576 $lock = (int) $lock;
577
578 if ( $lock < \time() - 1800 ) {
579 self::unlock( $key );
580 return false;
581 }
582
583 return true;
584 }
585
586 /**
587 * Send announces.
588 *
589 * @param int $outbox_activity_id The outbox activity ID.
590 * @param Activity $activity The activity object.
591 * @param int $actor_id The actor ID.
592 * @param int $content_visibility The content visibility.
593 */
594 public static function schedule_announce_activity( $outbox_activity_id, $activity, $actor_id, $content_visibility ) {
595 // Only if we're in both Blog and User modes.
596 if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE !== \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
597 return;
598 }
599
600 // Only if this isn't the Blog Actor.
601 if ( Actors::BLOG_USER_ID === $actor_id ) {
602 return;
603 }
604
605 // Only if the content is public or quiet public.
606 if ( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC !== $content_visibility ) {
607 return;
608 }
609
610 // Only if the activity is a Create.
611 if ( 'Create' !== $activity->get_type() ) {
612 return;
613 }
614
615 if ( ! is_object( $activity->get_object() ) ) {
616 return;
617 }
618
619 // Check if the object is an article, image, audio, video, event, or document and ignore profile updates and other activities.
620 if ( ! in_array( $activity->get_object()->get_type(), Base_Object::TYPES, true ) ) {
621 return;
622 }
623
624 $announce = new Activity();
625 $announce->set_type( 'Announce' );
626 $announce->set_actor( Actors::get_by_id( Actors::BLOG_USER_ID )->get_id() );
627 $announce->set_object( $activity );
628 $announce->add_cc( object_to_uri( $activity->get_actor() ) );
629
630 $outbox_activity_id = Outbox::add( $announce, Actors::BLOG_USER_ID );
631
632 if ( ! $outbox_activity_id ) {
633 return;
634 }
635
636 // Schedule the outbox item for federation.
637 self::schedule_outbox_activity_for_federation( $outbox_activity_id, 120 );
638 }
639 }
640