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

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

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