PluginProbe
ActivityPub / 8.2.0
ActivityPub v8.2.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
activitypub / includes / class-migration.php

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

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