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