PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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-migration.php

class-migration.php in ActivityPub trunk, at includes/class-migration.php

1,398 lines 43.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\Collection\Extra_Fields;
12 use Activitypub\Collection\Followers;
13 use Activitypub\Collection\Following;
14 use Activitypub\Collection\Inbox;
15 use Activitypub\Collection\Outbox;
16 use Activitypub\Collection\Remote_Actors;
17 use Activitypub\Transformer\Factory;
18
19 /**
20 * ActivityPub Migration Class
21 *
22 * @author Matthias Pfefferle
23 */
24 class Migration {
25 /**
26 * Initialize the class, registering WordPress hooks.
27 */
28 public static function init() {
29 self::maybe_migrate();
30
31 Scheduler::register_async_batch_callback( 'activitypub_migrate_from_0_17', array( self::class, 'migrate_from_0_17' ) );
32 Scheduler::register_async_batch_callback( 'activitypub_update_comment_counts', array( self::class, 'update_comment_counts' ) );
33 Scheduler::register_async_batch_callback( 'activitypub_create_post_outbox_items', array( self::class, 'create_post_outbox_items' ) );
34 Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) );
35 Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) );
36 Scheduler::register_async_batch_callback( 'activitypub_migrate_actor_emoji', array( self::class, 'migrate_actor_emoji' ) );
37 Scheduler::register_async_batch_callback( 'activitypub_backfill_statistics', array( Statistics::class, 'backfill_historical_stats' ) );
38 Scheduler::register_async_batch_callback( 'activitypub_tombstone_migrate', array( self::class, 'migrate_tombstones_to_cpt' ) );
39 }
40
41 /**
42 * The current version of the database structure.
43 *
44 * @return string The current version.
45 */
46 public static function get_version() {
47 return \get_option( 'activitypub_db_version', 0 );
48 }
49
50 /**
51 * Locks the database migration process to prevent simultaneous migrations.
52 *
53 * @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
54 */
55 public static function lock() {
56 global $wpdb;
57
58 // Try to lock.
59 $lock_result = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", 'activitypub_migration_lock', \time() ) ); // phpcs:ignore WordPress.DB
60
61 if ( ! $lock_result ) {
62 $lock_result = \get_option( 'activitypub_migration_lock' );
63 }
64
65 return $lock_result;
66 }
67
68 /**
69 * Unlocks the database migration process.
70 */
71 public static function unlock() {
72 \delete_option( 'activitypub_migration_lock' );
73 }
74
75 /**
76 * Whether the database migration process is locked.
77 *
78 * @return boolean
79 */
80 public static function is_locked() {
81 $lock = \get_option( 'activitypub_migration_lock' );
82
83 if ( ! $lock ) {
84 return false;
85 }
86
87 $lock = (int) $lock;
88
89 if ( $lock < \time() - 1800 ) {
90 self::unlock();
91 return false;
92 }
93
94 return true;
95 }
96
97 /**
98 * Whether the database structure is up to date.
99 *
100 * @return bool True if the database structure is up to date, false otherwise.
101 */
102 public static function is_latest_version() {
103 return (bool) \version_compare(
104 self::get_version(),
105 ACTIVITYPUB_PLUGIN_VERSION,
106 '=='
107 );
108 }
109
110 /**
111 * Updates the database structure if necessary.
112 */
113 public static function maybe_migrate() {
114 if ( self::is_latest_version() ) {
115 return;
116 }
117
118 if ( self::is_locked() ) {
119 return;
120 }
121
122 self::lock();
123
124 $version_from_db = self::get_version();
125
126 // Check for initial migration.
127 if ( ! $version_from_db ) {
128 self::add_default_settings();
129 $version_from_db = ACTIVITYPUB_PLUGIN_VERSION;
130 }
131
132 if ( \version_compare( $version_from_db, '0.17.0', '<' ) ) {
133 self::migrate_from_0_16();
134 }
135 if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
136 \wp_schedule_single_event( \time(), 'activitypub_migrate_from_0_17' );
137 }
138 if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
139 self::migrate_from_1_2_0();
140 }
141 if ( \version_compare( $version_from_db, '2.1.0', '<' ) ) {
142 self::migrate_from_2_0_0();
143 }
144 if ( \version_compare( $version_from_db, '2.3.0', '<' ) ) {
145 self::migrate_from_2_2_0();
146 }
147 if ( \version_compare( $version_from_db, '3.0.0', '<' ) ) {
148 self::migrate_from_2_6_0();
149 }
150 if ( \version_compare( $version_from_db, '4.0.0', '<' ) ) {
151 self::migrate_to_4_0_0();
152 }
153 if ( \version_compare( $version_from_db, '4.1.0', '<' ) ) {
154 self::migrate_to_4_1_0();
155 }
156 if ( \version_compare( $version_from_db, '4.5.0', '<' ) ) {
157 \wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_update_comment_counts' );
158 }
159 if ( \version_compare( $version_from_db, '4.7.1', '<' ) ) {
160 self::migrate_to_4_7_1();
161 }
162 if ( \version_compare( $version_from_db, '4.7.2', '<' ) ) {
163 self::migrate_to_4_7_2();
164 }
165 if ( \version_compare( $version_from_db, '5.0.0', '<' ) ) {
166 Scheduler::register_schedules();
167 \wp_schedule_single_event( \time(), 'activitypub_create_post_outbox_items' );
168 \wp_schedule_single_event( \time() + 15, 'activitypub_create_comment_outbox_items' );
169 }
170 if ( \version_compare( $version_from_db, '5.4.0', '<' ) ) {
171 \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_slashing' ) );
172 \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_comment_author_emails' ) );
173 }
174 if ( \version_compare( $version_from_db, '5.7.0', '<' ) ) {
175 self::delete_mastodon_api_orphaned_extra_fields();
176 }
177 if ( \version_compare( $version_from_db, '5.8.0', '<' ) ) {
178 self::update_notification_options();
179 }
180 if ( \version_compare( $version_from_db, '6.0.0', '<' ) ) {
181 self::migrate_followers_to_ap_actor_cpt();
182 \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
183 }
184 if ( \version_compare( $version_from_db, '6.0.1', '<' ) ) {
185 self::migrate_followers_to_ap_actor_cpt();
186 \wp_schedule_single_event( \time(), 'activitypub_upgrade', array( 'update_actor_json_storage' ) );
187 }
188 if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
189 \wp_unschedule_hook( 'activitypub_update_followers' );
190 \wp_unschedule_hook( 'activitypub_cleanup_followers' );
191
192 if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
193 \wp_schedule_event( \time(), 'hourly', 'activitypub_update_remote_actors' );
194 }
195
196 if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
197 \wp_schedule_event( \time(), 'daily', 'activitypub_cleanup_remote_actors' );
198 }
199 }
200 if ( \version_compare( $version_from_db, '7.3.0', '<' ) ) {
201 self::remove_pending_application_user_follow_requests();
202 }
203 if ( \version_compare( $version_from_db, '7.5.0', '<' ) ) {
204 self::sync_jetpack_following_meta();
205 }
206 if ( \version_compare( $version_from_db, '7.6.0', '<' ) ) {
207 self::clean_up_inbox();
208 \wp_schedule_single_event( \time(), 'activitypub_migrate_avatar_to_remote_actors' );
209 }
210 if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) {
211 \wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' );
212 }
213 if ( \version_compare( $version_from_db, '8.1.0', '<' ) && ! \wp_next_scheduled( 'activitypub_backfill_statistics' ) ) {
214 // Backfill historical statistics data (delay + jitter to avoid load spikes on hosts running many sites).
215 \wp_schedule_single_event( \time() + HOUR_IN_SECONDS + \wp_rand( 0, 6 * HOUR_IN_SECONDS ), 'activitypub_backfill_statistics' );
216 }
217
218 if ( \version_compare( $version_from_db, '8.3.0', '<' ) ) {
219 if ( ! \wp_next_scheduled( 'activitypub_tombstone_migrate' ) ) {
220 \wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, 'activitypub_tombstone_migrate' );
221 }
222 }
223 if ( \version_compare( $version_from_db, '9.1.0', '<' ) ) {
224 self::migrate_application_keypair_option();
225 self::delete_application_outbox_items();
226 }
227
228 /*
229 * Defer the flush to late in the `init` cycle (priority 20). Migration::init
230 * runs at priority 1, which is earlier than most plugins register their
231 * rewrite rules. Flushing synchronously here would persist a truncated
232 * ruleset that omits third-party rules added on `init` at priority 10.
233 */
234 \add_action( 'init', array( Activitypub::class, 'flush_rewrite_rules' ), 20 );
235
236 // Ensure all required cron schedules are registered.
237 Scheduler::register_schedules();
238
239 /*
240 * Add new update routines above this comment. ^
241 *
242 * Use 'unreleased' as the version number for new migrations and add tests for the callback directly.
243 * The release script will automatically replace it with the actual version number.
244 * Example:
245 *
246 * if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
247 * // Update routine.
248 * }
249 */
250
251 /**
252 * Fires when the system has to be migrated.
253 *
254 * @param string $version_from_db The version from which to migrate.
255 * @param string $target_version The target version to migrate to.
256 */
257 \do_action( 'activitypub_migrate', $version_from_db, ACTIVITYPUB_PLUGIN_VERSION );
258
259 \update_option( 'activitypub_db_version', ACTIVITYPUB_PLUGIN_VERSION );
260
261 self::unlock();
262 }
263
264 /**
265 * Updates the custom template to use shortcodes instead of the deprecated templates.
266 */
267 private static function migrate_from_0_16() {
268 // Get the custom template.
269 $old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
270
271 /*
272 * If the old content exists but is a blank string, we're going to need a flag to updated it even
273 * after setting it to the default contents.
274 */
275 $need_update = false;
276
277 // If the old contents is blank, use the defaults.
278 if ( '' === $old_content ) {
279 $old_content = ACTIVITYPUB_CUSTOM_POST_CONTENT;
280 $need_update = true;
281 }
282
283 // Set the new content to be the old content.
284 $content = $old_content;
285
286 // Convert old templates to shortcodes.
287 $content = \str_replace( '%title%', '[ap_title]', $content );
288 $content = \str_replace( '%excerpt%', '[ap_excerpt]', $content );
289 $content = \str_replace( '%content%', '[ap_content]', $content );
290 $content = \str_replace( '%permalink%', '[ap_permalink type="html"]', $content );
291 $content = \str_replace( '%shortlink%', '[ap_shortlink type="html"]', $content );
292 $content = \str_replace( '%hashtags%', '[ap_hashtags]', $content );
293 $content = \str_replace( '%tags%', '[ap_hashtags]', $content );
294
295 // Store the new template if required.
296 if ( $content !== $old_content || $need_update ) {
297 \update_option( 'activitypub_custom_post_content', $content );
298 }
299 }
300
301 /**
302 * Updates the DB-schema of the followers-list.
303 */
304 public static function migrate_from_0_17() {
305 // Migrate followers.
306 foreach ( \get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
307 $followers = \get_user_meta( $user_id, 'activitypub_followers', true );
308
309 if ( $followers ) {
310 foreach ( $followers as $actor ) {
311 Followers::add( $user_id, $actor );
312 }
313 }
314 }
315 }
316
317 /**
318 * Clear the cache after updating to 1.3.0.
319 */
320 private static function migrate_from_1_2_0() {
321 $user_ids = \get_users(
322 array(
323 'fields' => 'ID',
324 'capability__in' => array( 'publish_posts' ),
325 )
326 );
327
328 foreach ( $user_ids as $user_id ) {
329 \wp_cache_delete( \sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
330 }
331 }
332
333 /**
334 * Unschedule Hooks after updating to 2.0.0.
335 */
336 private static function migrate_from_2_0_0() {
337 \wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
338 \wp_clear_scheduled_hook( 'activitypub_send_update_activity' );
339 \wp_clear_scheduled_hook( 'activitypub_send_delete_activity' );
340
341 \wp_unschedule_hook( 'activitypub_send_post_activity' );
342 \wp_unschedule_hook( 'activitypub_send_update_activity' );
343 \wp_unschedule_hook( 'activitypub_send_delete_activity' );
344
345 $object_type = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
346 if ( 'article' === $object_type ) {
347 \update_option( 'activitypub_object_type', 'wordpress-post-format' );
348 }
349 }
350
351 /**
352 * Add the ActivityPub capability to all users that can publish posts
353 * Delete old meta to store followers.
354 */
355 private static function migrate_from_2_2_0() {
356 // Add the ActivityPub capability to all users that can publish posts.
357 self::add_activitypub_capability();
358 }
359
360 /**
361 * Rename DB fields.
362 */
363 private static function migrate_from_2_6_0() {
364 \wp_cache_flush();
365
366 self::update_usermeta_key( 'activitypub_user_description', 'activitypub_description' );
367
368 self::update_options_key( 'activitypub_blog_user_description', 'activitypub_blog_description' );
369 self::update_options_key( 'activitypub_blog_user_identifier', 'activitypub_blog_identifier' );
370 }
371
372 /**
373 * * Update actor-mode settings.
374 * * Get the ID of the latest blog post and save it to the options table.
375 */
376 private static function migrate_to_4_0_0() {
377 $latest_post_id = 0;
378
379 // Get the ID of the latest blog post and save it to the options table.
380 $latest_post = \get_posts(
381 array(
382 'numberposts' => 1,
383 'orderby' => 'ID',
384 'order' => 'DESC',
385 'post_type' => 'any',
386 'post_status' => 'publish',
387 )
388 );
389
390 if ( $latest_post ) {
391 $latest_post_id = $latest_post[0]->ID;
392 }
393
394 \update_option( 'activitypub_last_post_with_permalink_as_id', $latest_post_id );
395
396 $users = \get_users(
397 array(
398 'capability__in' => array( 'activitypub' ),
399 )
400 );
401
402 foreach ( $users as $user ) {
403 $followers = Followers::get_many( $user->ID );
404
405 if ( $followers ) {
406 \update_user_option( $user->ID, 'activitypub_use_permalink_as_id', '1' );
407 }
408 }
409
410 $followers = Followers::get_many( Actors::BLOG_USER_ID );
411
412 if ( $followers ) {
413 \update_option( 'activitypub_use_permalink_as_id_for_blog', '1' );
414 }
415
416 self::migrate_actor_mode();
417 }
418
419 /**
420 * Update to 4.1.0
421 *
422 * * Migrate the `activitypub_post_content_type` to only use `activitypub_custom_post_content`.
423 */
424 public static function migrate_to_4_1_0() {
425 $content_type = \get_option( 'activitypub_post_content_type' );
426
427 switch ( $content_type ) {
428 case 'excerpt':
429 $template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
430 break;
431 case 'title':
432 $template = "[ap_title type=\"html\"]\n\n[ap_permalink type=\"html\"]";
433 break;
434 case 'content':
435 $template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
436 break;
437 case 'custom':
438 $template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
439 break;
440 default:
441 $template = ACTIVITYPUB_CUSTOM_POST_CONTENT;
442 break;
443 }
444
445 \update_option( 'activitypub_custom_post_content', $template );
446
447 \delete_option( 'activitypub_post_content_type' );
448
449 $object_type = \get_option( 'activitypub_object_type', false );
450 if ( ! $object_type ) {
451 \update_option( 'activitypub_object_type', 'note' );
452 }
453
454 // Clean up empty visibility meta.
455 global $wpdb;
456 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
457 "DELETE FROM $wpdb->postmeta
458 WHERE meta_key = 'activitypub_content_visibility'
459 AND (meta_value IS NULL OR meta_value = '')"
460 );
461 }
462
463 /**
464 * Updates post meta keys to be prefixed with an underscore.
465 */
466 public static function migrate_to_4_7_1() {
467 global $wpdb;
468
469 $meta_keys = array(
470 'activitypub_actor_json',
471 'activitypub_canonical_url',
472 'activitypub_errors',
473 'activitypub_inbox',
474 'activitypub_user_id',
475 );
476
477 foreach ( $meta_keys as $meta_key ) {
478 // phpcs:ignore WordPress.DB
479 $wpdb->update( $wpdb->postmeta, array( 'meta_key' => '_' . $meta_key ), array( 'meta_key' => $meta_key ) );
480 }
481 }
482
483 /**
484 * Clears the post cache for Followers, we should have done this in 4.7.1 when we renamed those keys.
485 */
486 public static function migrate_to_4_7_2() {
487 global $wpdb;
488 // phpcs:ignore WordPress.DB
489 $followers = $wpdb->get_col(
490 $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", Remote_Actors::POST_TYPE )
491 );
492 foreach ( $followers as $id ) {
493 \clean_post_cache( $id );
494 }
495 }
496
497 /**
498 * Update comment counts for posts in batches.
499 *
500 * @see Comment::pre_wp_update_comment_count_now()
501 * @param int $batch_size Optional. Number of posts to process per batch. Default 100.
502 * @param int $offset Optional. Number of posts to skip. Default 0.
503 *
504 * @return int[]|void Array with batch size and offset if there are more posts to process.
505 */
506 public static function update_comment_counts( $batch_size = 100, $offset = 0 ) {
507 global $wpdb;
508
509 Comment::register_comment_types();
510 $comment_types = Comment::get_comment_type_slugs();
511 $type_inclusion = "AND comment_type IN ('" . \implode( "','", $comment_types ) . "')";
512
513 // Get and process this batch.
514 $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB
515 $wpdb->prepare(
516 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
517 "SELECT DISTINCT comment_post_ID FROM {$wpdb->comments} WHERE comment_approved = '1' {$type_inclusion} ORDER BY comment_post_ID LIMIT %d OFFSET %d",
518 $batch_size,
519 $offset
520 )
521 );
522
523 foreach ( $post_ids as $post_id ) {
524 \wp_update_comment_count_now( $post_id );
525 }
526
527 if ( \count( $post_ids ) === $batch_size ) {
528 // Schedule next batch.
529 return array( $batch_size, $offset + $batch_size );
530 }
531 }
532
533 /**
534 * Create outbox items for posts in batches.
535 *
536 * @param int $batch_size Optional. Number of posts to process per batch. Default 50.
537 * @param int $offset Optional. Number of posts to skip. Default 0.
538 * @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
539 */
540 public static function create_post_outbox_items( $batch_size = 50, $offset = 0 ) {
541 $posts = \get_posts(
542 array(
543 // our own `ap_outbox` will be excluded from `any` by virtue of its `exclude_from_search` arg.
544 'post_type' => 'any',
545 'posts_per_page' => $batch_size,
546 'offset' => $offset,
547 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
548 'meta_query' => array(
549 array(
550 'key' => 'activitypub_status',
551 'value' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
552 ),
553 ),
554 )
555 );
556
557 // Avoid multiple queries for post meta.
558 \update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) );
559
560 foreach ( $posts as $post ) {
561 $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
562
563 self::add_to_outbox( $post, 'Create', $post->post_author, $visibility );
564
565 // Add Update activity when the post has been modified.
566 if ( $post->post_modified !== $post->post_date ) {
567 self::add_to_outbox( $post, 'Update', $post->post_author, $visibility );
568 }
569 }
570
571 if ( \count( $posts ) === $batch_size ) {
572 return array(
573 'batch_size' => $batch_size,
574 'offset' => $offset + $batch_size,
575 );
576 }
577
578 return null;
579 }
580
581 /**
582 * Create outbox items for comments in batches.
583 *
584 * @param int $batch_size Optional. Number of posts to process per batch. Default 50.
585 * @param int $offset Optional. Number of posts to skip. Default 0.
586 * @return array|null Array with batch size and offset if there are more posts to process, null otherwise.
587 */
588 public static function create_comment_outbox_items( $batch_size = 50, $offset = 0 ) {
589 $comments = \get_comments(
590 array(
591 'author__not_in' => array( 0 ), // Limit to comments by registered users.
592 'number' => $batch_size,
593 'offset' => $offset,
594 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
595 'meta_query' => array(
596 array(
597 'key' => 'activitypub_status',
598 'value' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
599 ),
600 ),
601 )
602 );
603
604 foreach ( $comments as $comment ) {
605 self::add_to_outbox( $comment, 'Create', $comment->user_id );
606 }
607
608 if ( \count( $comments ) === $batch_size ) {
609 return array(
610 'batch_size' => $batch_size,
611 'offset' => $offset + $batch_size,
612 );
613 }
614
615 return null;
616 }
617
618 /**
619 * Update _activitypub_actor_json meta values to ensure they are properly slashed.
620 *
621 * @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
622 * @param int $offset Optional. Number of meta values to skip. Default 0.
623 * @return array|null Array with batch size and offset if there are more meta values to process, null otherwise.
624 */
625 public static function update_actor_json_slashing( $batch_size = 100, $offset = 0 ) {
626 global $wpdb;
627
628 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
629 $meta_values = $wpdb->get_results(
630 $wpdb->prepare(
631 "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d OFFSET %d",
632 $batch_size,
633 $offset
634 )
635 );
636
637 foreach ( $meta_values as $meta ) {
638 $json = \json_decode( $meta->meta_value, true );
639
640 // If json_decode fails, try adding slashes.
641 if ( null === $json && \json_last_error() !== JSON_ERROR_NONE ) {
642 $escaped_value = \preg_replace( '#\\\\(?!["\\\\/bfnrtu])#', '\\\\\\\\', $meta->meta_value );
643 $json = \json_decode( $escaped_value, true );
644
645 // Update the meta if json_decode succeeds with slashes.
646 if ( null !== $json && \json_last_error() === JSON_ERROR_NONE ) {
647 \update_post_meta( $meta->post_id, '_activitypub_actor_json', \wp_slash( $escaped_value ) );
648 }
649 }
650 }
651
652 if ( \count( $meta_values ) === $batch_size ) {
653 return array(
654 'batch_size' => $batch_size,
655 'offset' => $offset + $batch_size,
656 );
657 }
658
659 return null;
660 }
661
662 /**
663 * Update comment author emails with webfinger addresses for ActivityPub comments.
664 *
665 * @param int $batch_size Optional. Number of comments to process per batch. Default 50.
666 * @param int $offset Optional. Number of comments to skip. Default 0.
667 * @return array|null Array with batch size and offset if there are more comments to process, null otherwise.
668 */
669 public static function update_comment_author_emails( $batch_size = 50, $offset = 0 ) {
670 $comments = \get_comments(
671 array(
672 'number' => $batch_size,
673 'offset' => $offset,
674 'orderby' => 'comment_ID',
675 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
676 'meta_query' => array(
677 array(
678 'key' => 'protocol',
679 'value' => 'activitypub',
680 ),
681 ),
682 )
683 );
684
685 foreach ( $comments as $comment ) {
686 $comment_author_url = $comment->comment_author_url;
687 if ( empty( $comment_author_url ) ) {
688 continue;
689 }
690
691 $webfinger = Webfinger::uri_to_acct( $comment_author_url );
692 if ( \is_wp_error( $webfinger ) ) {
693 continue;
694 }
695
696 \wp_update_comment(
697 array(
698 'comment_ID' => $comment->comment_ID,
699 'comment_author_email' => \str_replace( 'acct:', '', $webfinger ),
700 )
701 );
702 }
703
704 if ( \count( $comments ) === $batch_size ) {
705 return array(
706 'batch_size' => $batch_size,
707 'offset' => $offset + $batch_size,
708 );
709 }
710
711 return null;
712 }
713
714 /**
715 * Set the defaults needed for the plugin to work.
716 *
717 * Add the ActivityPub capability to all users that can publish posts.
718 */
719 public static function add_default_settings() {
720 self::add_activitypub_capability();
721 self::add_default_extra_field();
722 }
723
724 /**
725 * Add an activity to the outbox without federating it.
726 *
727 * @param \WP_Post|\WP_Comment $comment The comment or post object.
728 * @param string $activity_type The type of activity.
729 * @param int $user_id The user ID.
730 * @param string $visibility Optional. The visibility of the content. Default 'public'.
731 */
732 private static function add_to_outbox( $comment, $activity_type, $user_id, $visibility = ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC ) {
733 $transformer = Factory::get_transformer( $comment );
734 if ( ! $transformer || \is_wp_error( $transformer ) ) {
735 return;
736 }
737
738 $activity = $transformer->to_activity( $activity_type );
739 if ( ! $activity || \is_wp_error( $activity ) ) {
740 return;
741 }
742
743 // If the user is disabled, fall back to the blog user when available.
744 if ( ! user_can_activitypub( $user_id ) ) {
745 if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
746 $user_id = Actors::BLOG_USER_ID;
747 } else {
748 return;
749 }
750 }
751
752 $post_id = Outbox::add( $activity, $user_id, $visibility );
753
754 // Immediately set to publish, no federation needed.
755 \wp_publish_post( $post_id );
756 }
757
758 /**
759 * Add the ActivityPub capability to all users that can publish posts.
760 */
761 private static function add_activitypub_capability() {
762 // Get all WP_User objects that can publish posts.
763 $users = \get_users(
764 array(
765 'capability__in' => array( 'publish_posts' ),
766 )
767 );
768
769 // Add ActivityPub capability to all users that can publish posts.
770 foreach ( $users as $user ) {
771 $user->add_cap( 'activitypub' );
772 }
773 }
774
775 /**
776 * Add a default extra field for the user.
777 */
778 private static function add_default_extra_field() {
779 $users = \get_users(
780 array(
781 'capability__in' => array( 'activitypub' ),
782 )
783 );
784
785 $title = \__( 'Powered by', 'activitypub' );
786 $content = 'WordPress';
787
788 // Add a default extra field for each user.
789 foreach ( $users as $user ) {
790 \wp_insert_post(
791 array(
792 'post_type' => Extra_Fields::USER_POST_TYPE,
793 'post_author' => $user->ID,
794 'post_status' => 'publish',
795 'post_title' => $title,
796 'post_content' => $content,
797 )
798 );
799 }
800
801 \wp_insert_post(
802 array(
803 'post_type' => Extra_Fields::BLOG_POST_TYPE,
804 'post_author' => 0,
805 'post_status' => 'publish',
806 'post_title' => $title,
807 'post_content' => $content,
808 )
809 );
810 }
811
812 /**
813 * Rename user meta keys.
814 *
815 * @param string $old_key The old comment meta key.
816 * @param string $new_key The new comment meta key.
817 */
818 private static function update_usermeta_key( $old_key, $new_key ) {
819 global $wpdb;
820
821 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
822 $wpdb->usermeta,
823 array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
824 array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
825 array( '%s' ),
826 array( '%s' )
827 );
828 }
829
830 /**
831 * Update post meta keys.
832 *
833 * @param string $old_key The old post meta key.
834 * @param string $new_key The new post meta key.
835 */
836 private static function update_postmeta_key( $old_key, $new_key ) {
837 global $wpdb;
838
839 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
840 $wpdb->postmeta,
841 array( 'meta_key' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
842 array( 'meta_key' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
843 array( '%s' ),
844 array( '%s' )
845 );
846 }
847
848 /**
849 * Rename option keys.
850 *
851 * @param string $old_key The old option key.
852 * @param string $new_key The new option key.
853 */
854 private static function update_options_key( $old_key, $new_key ) {
855 global $wpdb;
856
857 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
858 $wpdb->options,
859 array( 'option_name' => $new_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
860 array( 'option_name' => $old_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
861 array( '%s' ),
862 array( '%s' )
863 );
864 }
865
866 /**
867 * Migrate the actor mode settings.
868 */
869 public static function migrate_actor_mode() {
870 $blog_profile = \get_option( 'activitypub_enable_blog_user', '0' );
871 $author_profiles = \get_option( 'activitypub_enable_users', '1' );
872
873 if (
874 '1' === $blog_profile &&
875 '1' === $author_profiles
876 ) {
877 \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE );
878 } elseif (
879 '1' === $blog_profile &&
880 '1' !== $author_profiles
881 ) {
882 \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE );
883 } elseif (
884 '1' !== $blog_profile &&
885 '1' === $author_profiles
886 ) {
887 \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
888 }
889 }
890
891 /**
892 * Deletes user extra fields where the author is the blog user.
893 *
894 * These extra fields were created when the Enable Mastodon Apps integration passed
895 * an author_url instead of a user_id to the mastodon_api_account filter. This caused
896 * Extra_Fields::default_actor_extra_fields() to run but fail to cache the fact it ran
897 * for non-existent users. The result is a number of user extra fields with no author.
898 *
899 * @ticket https://github.com/Automattic/wordpress-activitypub/pull/1554
900 */
901 public static function delete_mastodon_api_orphaned_extra_fields() {
902 global $wpdb;
903
904 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
905 $wpdb->delete(
906 $wpdb->posts,
907 array(
908 'post_type' => Extra_Fields::USER_POST_TYPE,
909 'post_author' => Actors::BLOG_USER_ID,
910 )
911 );
912 }
913
914 /**
915 * Update notification options.
916 */
917 public static function update_notification_options() {
918 $new_dm = \get_option( 'activitypub_mailer_new_dm', '1' );
919 $new_follower = \get_option( 'activitypub_mailer_new_follower', '1' );
920
921 // Add the blog user notification options.
922 \add_option( 'activitypub_blog_user_mailer_new_dm', $new_dm );
923 \add_option( 'activitypub_blog_user_mailer_new_follower', $new_follower );
924 \add_option( 'activitypub_blog_user_mailer_new_mention', '1' );
925
926 $user_ids = \get_users(
927 array(
928 'capability__in' => array( 'activitypub' ),
929 'fields' => 'id',
930 )
931 );
932
933 // Add the actor notification options.
934 foreach ( $user_ids as $user_id ) {
935 \update_user_option( $user_id, 'activitypub_mailer_new_dm', $new_dm );
936 \update_user_option( $user_id, 'activitypub_mailer_new_follower', $new_follower );
937 \update_user_option( $user_id, 'activitypub_mailer_new_mention', '1' );
938 }
939
940 // Delete the old notification options.
941 \delete_option( 'activitypub_mailer_new_dm' );
942 \delete_option( 'activitypub_mailer_new_follower' );
943 }
944
945 /**
946 * Migrate followers to the new CPT.
947 */
948 public static function migrate_followers_to_ap_actor_cpt() {
949 global $wpdb;
950
951 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
952 $wpdb->posts,
953 array( 'post_type' => Remote_Actors::POST_TYPE ),
954 array( 'post_type' => 'ap_follower' ),
955 array( '%s' ),
956 array( '%s' )
957 );
958
959 self::update_postmeta_key( '_activitypub_user_id', Followers::FOLLOWER_META_KEY );
960 }
961
962 /**
963 * Update _activitypub_actor_json meta values to ensure they are properly slashed.
964 *
965 * @param int $batch_size Optional. Number of meta values to process per batch. Default 100.
966 *
967 * @return array|void Array with batch size and offset if there are more meta values to process, void otherwise.
968 */
969 public static function update_actor_json_storage( $batch_size = 100 ) {
970 global $wpdb;
971
972 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
973 $meta_values = $wpdb->get_results(
974 $wpdb->prepare(
975 "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_actor_json' LIMIT %d",
976 $batch_size
977 )
978 );
979
980 $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' );
981 if ( $has_kses ) {
982 // Prevent KSES from corrupting JSON in post_content.
983 \kses_remove_filters();
984 }
985
986 foreach ( $meta_values as $meta ) {
987 $post = \get_post( $meta->post_id );
988
989 if ( ! $post ) {
990 \delete_post_meta( $meta->post_id, '_activitypub_actor_json' );
991 continue;
992 }
993
994 $post_content = \json_decode( $meta->meta_value, true );
995
996 if ( \json_last_error() !== JSON_ERROR_NONE ) {
997 $post_content = Http::get_remote_object( $post->guid );
998
999 if ( \is_wp_error( $post_content ) ) {
1000 \delete_post_meta( $post->ID, '_activitypub_actor_json' );
1001 continue;
1002 }
1003 }
1004
1005 \wp_update_post(
1006 array(
1007 'ID' => $post->ID,
1008 'post_content' => \wp_slash( \wp_json_encode( $post_content ) ),
1009 )
1010 );
1011
1012 \delete_post_meta( $post->ID, '_activitypub_actor_json' );
1013 }
1014
1015 if ( $has_kses ) {
1016 // Restore KSES filters.
1017 \kses_init_filters();
1018 }
1019
1020 if ( \count( $meta_values ) === $batch_size ) {
1021 return array(
1022 'batch_size' => $batch_size,
1023 );
1024 }
1025 }
1026
1027 /**
1028 * Removes pending follow requests for the application user.
1029 */
1030 public static function remove_pending_application_user_follow_requests() {
1031 global $wpdb;
1032
1033 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1034 $wpdb->delete(
1035 $wpdb->postmeta,
1036 array(
1037 'meta_key' => '_activitypub_following', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1038 'meta_value' => -1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1039 )
1040 );
1041 }
1042
1043 /**
1044 * Sync Jetpack meta for all followings.
1045 *
1046 * Replays the added_post_meta sync action for Jetpack with the Following::FOLLOWING_META_KEY meta key.
1047 */
1048 public static function sync_jetpack_following_meta() {
1049 if ( ! \class_exists( 'Jetpack' ) || ! \Jetpack::is_connection_ready() ) {
1050 return;
1051 }
1052
1053 global $wpdb;
1054
1055 // Get all posts that have the following meta key.
1056 $posts_with_following = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1057 $wpdb->prepare(
1058 "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
1059 Following::FOLLOWING_META_KEY
1060 ),
1061 ARRAY_N
1062 );
1063
1064 // Trigger the added_post_meta action for each following relationship.
1065 foreach ( $posts_with_following as $meta ) {
1066 /**
1067 * Fires when post meta is added.
1068 *
1069 * @param int $meta_id ID of the metadata entry.
1070 * @param int $object_id Post ID.
1071 * @param string $meta_key Metadata key.
1072 * @param mixed $meta_value Metadata value.
1073 */
1074 \do_action( 'added_post_meta', ...$meta );
1075 }
1076 }
1077
1078 /**
1079 * Clean up inbox items for shared inbox migration.
1080 *
1081 * Deletes all existing inbox items to prepare for the new shared inbox structure
1082 * where activities are stored once with multiple recipients as metadata.
1083 */
1084 private static function clean_up_inbox() {
1085 global $wpdb;
1086
1087 // Get all inbox post IDs.
1088 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1089 $inbox_ids = $wpdb->get_col(
1090 $wpdb->prepare(
1091 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s",
1092 Inbox::POST_TYPE
1093 )
1094 );
1095
1096 // Delete all inbox items and their metadata.
1097 foreach ( $inbox_ids as $post_id ) {
1098 \wp_delete_post( $post_id, true );
1099 }
1100 }
1101
1102 /**
1103 * Migrate URLs from the legacy `activitypub_tombstone_urls` option into the
1104 * `ap_tombstone` custom post type.
1105 *
1106 * Chunked async migration. Locking and rescheduling is handled by
1107 * Scheduler::async_batch — the callback returns `array( 'batch_size' => N )`
1108 * to request another run, or `null` when the option is fully drained.
1109 *
1110 * Legacy entries are already-normalized strings (no scheme), so we bypass
1111 * URL validation and insert directly via wp_insert_post.
1112 *
1113 * @since 8.3.0
1114 *
1115 * @param int $batch_size Optional. Number of URLs to process per call. Default 500.
1116 * @return array|null Args for the next run, or null when migration is complete.
1117 */
1118 public static function migrate_tombstones_to_cpt( $batch_size = 500 ) {
1119 global $wpdb;
1120
1121 $urls = \get_option( 'activitypub_tombstone_urls', null );
1122
1123 if ( null === $urls || ! \is_array( $urls ) || empty( $urls ) ) {
1124 \delete_option( 'activitypub_tombstone_urls' );
1125 return null;
1126 }
1127
1128 $chunk = \array_slice( $urls, 0, (int) $batch_size );
1129 $remaining = \array_slice( $urls, (int) $batch_size );
1130 $progressed = false;
1131
1132 foreach ( $chunk as $normalized ) {
1133 if ( ! \is_string( $normalized ) || '' === $normalized ) {
1134 // Drop garbage entries — counts as progress.
1135 $progressed = true;
1136 continue;
1137 }
1138
1139 $hash = \md5( $normalized );
1140
1141 /*
1142 * Light existence check. `get_page_by_path()` would hydrate a
1143 * full `WP_Post` per loop iteration; on a large registry that
1144 * adds up fast. We only need a boolean here.
1145 */
1146 $exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1147 $wpdb->prepare(
1148 "SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s AND post_name = %s LIMIT 1",
1149 Tombstone::POST_TYPE,
1150 $hash
1151 )
1152 );
1153 if ( $exists ) {
1154 $progressed = true;
1155 continue;
1156 }
1157
1158 /*
1159 * `guid` is intentionally omitted: the legacy option only kept
1160 * the normalized (schemeless) form, so we can't reconstruct the
1161 * original URL. Storing the schemeless string would be mangled
1162 * by `esc_url()`. Leave WordPress to auto-generate the guid
1163 * — it's not used for lookups, only for debugging.
1164 */
1165 $result = \wp_insert_post(
1166 array(
1167 'post_type' => Tombstone::POST_TYPE,
1168 'post_status' => 'publish',
1169 'post_name' => $hash,
1170 'post_author' => 0,
1171 ),
1172 true
1173 );
1174
1175 if ( \is_wp_error( $result ) || ! $result ) {
1176 /*
1177 * Keep failed inserts in the legacy option so the next batch
1178 * retries them. `Tombstone::exists_local()` still falls back
1179 * to the option, so the tombstone remains discoverable.
1180 */
1181 $remaining[] = $normalized;
1182 } else {
1183 $progressed = true;
1184 }
1185 }
1186
1187 if ( empty( $remaining ) ) {
1188 \delete_option( 'activitypub_tombstone_urls' );
1189 return null;
1190 }
1191
1192 /*
1193 * Disable autoload while we drain. The point of the migration is to
1194 * stop this option from contributing to `alloptions` pressure, so
1195 * flip the flag immediately rather than waiting for the option to
1196 * be fully empty before the relief kicks in.
1197 */
1198 \update_option( 'activitypub_tombstone_urls', \array_values( $remaining ), false );
1199
1200 /*
1201 * If nothing in this batch was drained — every insert errored and
1202 * nothing was already migrated — halt the scheduler so we don't loop
1203 * forever on a persistent failure. The legacy option still backs
1204 * exists_local(), so the data isn't lost; an admin can re-trigger
1205 * the migration via `wp cron event run activitypub_tombstone_migrate`
1206 * after fixing the underlying cause.
1207 */
1208 if ( ! $progressed ) {
1209 return null;
1210 }
1211
1212 return array( 'batch_size' => (int) $batch_size );
1213 }
1214
1215 /**
1216 * Migrate avatar URLs from comment meta to remote actors in batches.
1217 *
1218 * This migration:
1219 * 1. Finds all comments with ActivityPub protocol and avatar_url meta
1220 * 2. Looks up the remote actor by comment_author_url
1221 * 3. Adds _activitypub_remote_actor_id to comment meta
1222 * 4. Stores avatar_url in remote actor post meta
1223 *
1224 * Note: We don't use offset because as we add _activitypub_remote_actor_id,
1225 * comments are filtered out of the query. We just keep fetching the next
1226 * batch until no more comments match the criteria.
1227 *
1228 * @param int $batch_size Optional. Number of comments to process per batch. Default 50.
1229 * @return array|null Array with batch size if there are more comments to process, null otherwise.
1230 */
1231 public static function migrate_avatar_to_remote_actors( $batch_size = 50 ) {
1232 global $wpdb;
1233
1234 /*
1235 * Get comments with avatar_url meta that don't have _activitypub_remote_actor_id yet.
1236 * Uses conditional aggregation to reduce JOINs from 3 to 1, improving query performance.
1237 * Filters meta_key before GROUP BY to reduce rows processed during aggregation.
1238 * No offset needed - as we process comments, they're filtered out by the HAVING clause.
1239 */
1240 $comments = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1241 $wpdb->prepare(
1242 "SELECT c.comment_ID, c.comment_author_url,
1243 MAX(CASE WHEN cm.meta_key = 'avatar_url' THEN cm.meta_value END) AS avatar_url,
1244 MAX(CASE WHEN cm.meta_key = 'protocol' THEN cm.meta_value END) AS protocol,
1245 MAX(CASE WHEN cm.meta_key = '_activitypub_remote_actor_id' THEN cm.meta_value END) AS remote_actor_id
1246 FROM {$wpdb->comments} c
1247 INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
1248 WHERE cm.meta_key IN ('avatar_url', 'protocol', '_activitypub_remote_actor_id')
1249 GROUP BY c.comment_ID, c.comment_author_url
1250 HAVING protocol = 'activitypub'
1251 AND avatar_url IS NOT NULL
1252 AND (remote_actor_id IS NULL OR remote_actor_id = '')
1253 LIMIT %d",
1254 $batch_size
1255 )
1256 );
1257
1258 foreach ( $comments as $comment ) {
1259 if ( empty( $comment->comment_author_url ) ) {
1260 continue;
1261 }
1262
1263 // Try to get the remote actor by URI.
1264 $remote_actor = Remote_Actors::fetch_by_uri( $comment->comment_author_url );
1265
1266 // If we have a valid remote actor, store the reference.
1267 if ( ! \is_wp_error( $remote_actor ) ) {
1268 // Add _activitypub_remote_actor_id to comment meta.
1269 \add_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', $remote_actor->ID, true );
1270
1271 // Ensure avatar is stored on remote actor if not already present.
1272 $existing_avatar = \get_post_meta( $remote_actor->ID, '_activitypub_avatar_url', true );
1273 if ( empty( $existing_avatar ) && ! empty( $comment->avatar_url ) ) {
1274 \update_post_meta( $remote_actor->ID, '_activitypub_avatar_url', \esc_url_raw( $comment->avatar_url ) );
1275 }
1276 }
1277 }
1278
1279 // Return batch info if there are more comments to process.
1280 if ( \count( $comments ) === $batch_size ) {
1281 return array(
1282 'batch_size' => $batch_size,
1283 );
1284 }
1285
1286 return null;
1287 }
1288
1289 /**
1290 * Migrate emoji data from stored actor JSON to post meta.
1291 *
1292 * This migration:
1293 * 1. Finds all remote actor posts without _activitypub_emoji meta
1294 * 2. Extracts emoji from stored JSON in post_content
1295 * 3. Stores as _activitypub_emoji post meta
1296 *
1297 * @param int $batch_size Optional. Number of actors to process per batch. Default 50.
1298 * @param int $offset Optional. Offset for pagination. Default 0.
1299 * @return array|null Array with batch size if there are more actors to process, null otherwise.
1300 */
1301 public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) {
1302 $actors = \get_posts(
1303 array(
1304 'post_type' => Remote_Actors::POST_TYPE,
1305 'posts_per_page' => $batch_size,
1306 'offset' => $offset,
1307 'post_status' => 'any',
1308 'orderby' => 'ID',
1309 'order' => 'ASC',
1310 )
1311 );
1312
1313 foreach ( $actors as $actor_post ) {
1314 if ( empty( $actor_post->post_content ) ) {
1315 continue;
1316 }
1317
1318 $actor_data = \json_decode( $actor_post->post_content, true );
1319 if ( ! $actor_data ) {
1320 continue;
1321 }
1322
1323 $emoji_meta = Emoji::prepare_actor_meta( $actor_data );
1324 if ( ! empty( $emoji_meta['_activitypub_emoji'] ) ) {
1325 \update_post_meta( $actor_post->ID, '_activitypub_emoji', $emoji_meta['_activitypub_emoji'] );
1326 }
1327 }
1328
1329 // Return batch info if there are more actors to process.
1330 if ( \count( $actors ) === $batch_size ) {
1331 return array(
1332 'batch_size' => $batch_size,
1333 'offset' => $offset + $batch_size,
1334 );
1335 }
1336
1337 return null;
1338 }
1339
1340 /**
1341 * Migrate the Application key pair option from the old name to the new name.
1342 *
1343 * Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair`.
1344 * Older separate key options (activitypub_application_user_public_key /
1345 * activitypub_application_user_private_key) are migrated lazily on first read.
1346 *
1347 * @since 9.1.0
1348 */
1349 public static function migrate_application_keypair_option() {
1350 self::update_options_key( 'activitypub_keypair_for_-1', Application::KEYPAIR_OPTION_KEY );
1351
1352 // The raw rename bypasses the options API, so drop only the two stale option caches (plus the autoload bucket) instead of flushing everything.
1353 \wp_cache_delete( 'activitypub_keypair_for_-1', 'options' );
1354 \wp_cache_delete( Application::KEYPAIR_OPTION_KEY, 'options' );
1355 \wp_cache_delete( 'alloptions', 'options' );
1356
1357 /*
1358 * If an early Application::get_keypair() read already created the destination
1359 * option, the rename above is a no-op blocked by the unique `option_name`,
1360 * leaving the legacy row behind. Drop it once the destination is in place.
1361 */
1362 if ( false !== \get_option( Application::KEYPAIR_OPTION_KEY, false ) ) {
1363 \delete_option( 'activitypub_keypair_for_-1' );
1364 }
1365 }
1366
1367 /**
1368 * Delete outbox items that belonged to the Application actor.
1369 *
1370 * The Application used to queue Reject activities through the Outbox as user
1371 * ID -1. It no longer dispatches activities, so any pending items are
1372 * undeliverable and are removed.
1373 *
1374 * @since 9.1.0
1375 */
1376 public static function delete_application_outbox_items() {
1377 $items = \get_posts(
1378 array(
1379 'post_type' => Outbox::POST_TYPE,
1380 'post_status' => 'any',
1381 'nopaging' => true,
1382 'fields' => 'ids',
1383 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1384 'meta_query' => array(
1385 array(
1386 'key' => '_activitypub_activity_actor',
1387 'value' => 'application',
1388 ),
1389 ),
1390 )
1391 );
1392
1393 foreach ( $items as $item_id ) {
1394 \wp_delete_post( $item_id, true );
1395 }
1396 }
1397 }
1398