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
← All changes | includes/class-migration.php +1069 -50 2.0.18.0.2 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,19 @@
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' ) );
35 + Scheduler::register_async_batch_callback( 'activitypub_migrate_actor_emoji', array( self::class, 'migrate_actor_emoji' ) );
31 36 }
32 37
33 38 /**
34 39 * The current version of the database structure.
@@ -41,18 +46,25 @@
41 46
42 47 /**
43 48 * Locks the database migration process to prevent simultaneous migrations.
44 49 *
45 - * @return void
50 + * @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
46 51 */
47 52 public static function lock() {
48 - \update_option( 'activitypub_migration_lock', \time() );
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;
49 63 }
50 64
51 65 /**
52 66 * Unlocks the database migration process.
53 - *
54 - * @return void
55 67 */
56 68 public static function unlock() {
57 69 \delete_option( 'activitypub_migration_lock' );
58 70 }
@@ -84,11 +96,11 @@
84 96 *
85 97 * @return bool True if the database structure is up to date, false otherwise.
86 98 */
87 99 public static function is_latest_version() {
88 - return (bool) version_compare(
100 + return (bool) \version_compare(
89 101 self::get_version(),
90 - self::get_target_version(),
102 + ACTIVITYPUB_PLUGIN_VERSION,
91 103 '=='
92 104 );
93 105 }
94 106
@@ -107,54 +119,151 @@
107 119 self::lock();
108 120
109 121 $version_from_db = self::get_version();
110 122
111 - if ( version_compare( $version_from_db, '0.17.0', '<' ) ) {
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', '<' ) ) {
112 130 self::migrate_from_0_16();
113 131 }
114 - if ( version_compare( $version_from_db, '1.0.0', '<' ) ) {
115 - self::migrate_from_0_17();
132 + if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
133 + \wp_schedule_single_event( \time(), 'activitypub_migrate_from_0_17' );
116 134 }
117 - if ( version_compare( $version_from_db, '1.3.0', '<' ) ) {
135 + if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
118 136 self::migrate_from_1_2_0();
119 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 + }
120 182
121 - update_option( 'activitypub_db_version', self::get_target_version() );
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 + }
122 187
123 - self::unlock();
124 - }
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 + }
125 192
126 - /**
127 - * Updates the DB-schema of the followers-list
128 - *
129 - * @return void
130 - */
131 - private static function migrate_from_0_17() {
132 - // migrate followers
133 - foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
134 - $followers = get_user_meta( $user_id, 'activitypub_followers', true );
193 + if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
194 + wp_unschedule_hook( 'activitypub_update_followers' );
195 + wp_unschedule_hook( 'activitypub_cleanup_followers' );
135 196
136 - if ( $followers ) {
137 - foreach ( $followers as $actor ) {
138 - Followers::add_follower( $user_id, $actor );
139 - }
197 + if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
198 + \wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
140 199 }
200 +
201 + if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
202 + \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
203 + }
141 204 }
142 205
143 - Activitypub::flush_rewrite_rules();
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();
144 253 }
145 254
146 255 /**
147 256 * Updates the custom template to use shortcodes instead of the deprecated templates.
148 - *
149 - * @return void
150 257 */
151 258 private static function migrate_from_0_16() {
152 259 // Get the custom template.
153 260 $old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
154 261
155 - // If the old content exists but is a blank string, we're going to need a flag to updated it even
156 - // after setting it to the default contents.
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 + */
157 266 $need_update = false;
158 267
159 268 // If the old contents is blank, use the defaults.
160 269 if ( '' === $old_content ) {
@@ -180,14 +289,30 @@
180 289 }
181 290 }
182 291
183 292 /**
184 - * Clear the cache after updating to 1.3.0
185 - *
186 - * @return void
293 + * Updates the DB-schema of the followers-list.
187 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 + */
188 313 private static function migrate_from_1_2_0() {
189 - $user_ids = get_users(
314 + $user_ids = \get_users(
190 315 array(
191 316 'fields' => 'ID',
192 317 'capability__in' => array( 'publish_posts' ),
193 318 )
@@ -195,6 +320,900 @@
195 320
196 321 foreach ( $user_ids as $user_id ) {
197 322 wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
198 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;
199 1218 }
200 1219 }