PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-migration.php

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

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