PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.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
← All changes | includes/class-migration.php +1265 -47 1.2.09.2.1 View file →
@@ -1,10 +1,21 @@
1 1 <?php
2 +/**
3 + * Migration class file.
4 + *
5 + * @package Activitypub
6 + */
7 +
2 8 namespace Activitypub;
3 9
4 -use Activitypub\Activitypub;
5 -use Activitypub\Model\Blog_User;
10 +use Activitypub\Collection\Actors;
11 +use Activitypub\Collection\Extra_Fields;
6 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;
7 18
8 19 /**
9 20 * ActivityPub Migration Class
10 21 *
@@ -11,24 +22,21 @@
11 22 * @author Matthias Pfefferle
12 23 */
13 24 class Migration {
14 25 /**
15 - * Initialize the class, registering WordPress hooks
26 + * Initialize the class, registering WordPress hooks.
16 27 */
17 28 public static function init() {
18 - \add_action( 'activitypub_schedule_migration', array( self::class, 'maybe_migrate' ) );
19 - }
29 + self::maybe_migrate();
20 30
21 - /**
22 - * Get the target version.
23 - *
24 - * This is the version that the database structure will be updated to.
25 - * It is the same as the plugin version.
26 - *
27 - * @return string The target version.
28 - */
29 - public static function get_target_version() {
30 - return get_plugin_version();
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' ) );
31 39 }
32 40
33 41 /**
34 42 * The current version of the database structure.
@@ -35,24 +43,31 @@
35 43 *
36 44 * @return string The current version.
37 45 */
38 46 public static function get_version() {
39 - return get_option( 'activitypub_db_version', 0 );
47 + return \get_option( 'activitypub_db_version', 0 );
40 48 }
41 49
42 50 /**
43 51 * Locks the database migration process to prevent simultaneous migrations.
44 52 *
45 - * @return void
53 + * @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
46 54 */
47 55 public static function lock() {
48 - \update_option( 'activitypub_migration_lock', \time() );
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;
49 66 }
50 67
51 68 /**
52 69 * Unlocks the database migration process.
53 - *
54 - * @return void
55 70 */
56 71 public static function unlock() {
57 72 \delete_option( 'activitypub_migration_lock' );
58 73 }
@@ -84,11 +99,11 @@
84 99 *
85 100 * @return bool True if the database structure is up to date, false otherwise.
86 101 */
87 102 public static function is_latest_version() {
88 - return (bool) version_compare(
103 + return (bool) \version_compare(
89 104 self::get_version(),
90 - self::get_target_version(),
105 + ACTIVITYPUB_PLUGIN_VERSION,
91 106 '=='
92 107 );
93 108 }
94 109
@@ -107,51 +122,157 @@
107 122 self::lock();
108 123
109 124 $version_from_db = self::get_version();
110 125
111 - if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
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', '<' ) ) {
112 133 self::migrate_from_0_16();
113 134 }
114 - if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
115 - self::migrate_from_0_17();
135 + if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
136 + \wp_schedule_single_event( \time(), 'activitypub_migrate_from_0_17' );
116 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' );
117 191
118 - update_option( 'activitypub_db_version', self::get_target_version() );
192 + if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
193 + \wp_schedule_event( \time(), 'hourly', 'activitypub_update_remote_actors' );
194 + }
119 195
120 - self::unlock();
121 - }
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 + }
122 217
123 - /**
124 - * Updates the DB-schema of the followers-list
125 - *
126 - * @return void
127 - */
128 - private static function migrate_from_0_17() {
129 - // migrate followers
130 - foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
131 - $followers = get_user_meta( $user_id, 'activitypub_followers', true );
132 -
133 - if ( $followers ) {
134 - foreach ( $followers as $actor ) {
135 - Followers::add_follower( $user_id, $actor );
136 - }
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' );
137 221 }
138 222 }
223 + if ( \version_compare( $version_from_db, '9.1.0', '<' ) ) {
224 + self::migrate_application_keypair_option();
225 + self::delete_application_outbox_items();
226 + }
139 227
140 - Activitypub::flush_rewrite_rules();
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();
141 262 }
142 263
143 264 /**
144 265 * Updates the custom template to use shortcodes instead of the deprecated templates.
145 - *
146 - * @return void
147 266 */
148 267 private static function migrate_from_0_16() {
149 268 // Get the custom template.
150 269 $old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
151 270
152 - // If the old content exists but is a blank string, we're going to need a flag to updated it even
153 - // after setting it to the default contents.
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 + */
154 275 $need_update = false;
155 276
156 277 // If the old contents is blank, use the defaults.
157 278 if ( '' === $old_content ) {
@@ -173,7 +294,1104 @@
173 294
174 295 // Store the new template if required.
175 296 if ( $content !== $old_content || $need_update ) {
176 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 );
177 1395 }
178 1396 }
179 1397 }