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