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

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

1,160 lines 34.9 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 }
36
37 /**
38 * The current version of the database structure.
39 *
40 * @return string The current version.
41 */
42 public static function get_version() {
43 return get_option( 'activitypub_db_version', 0 );
44 }
45
46 /**
47 * Locks the database migration process to prevent simultaneous migrations.
48 *
49 * @return bool|int True if the lock was successful, timestamp of existing lock otherwise.
50 */
51 public static function lock() {
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;
62 }
63
64 /**
65 * Unlocks the database migration process.
66 */
67 public static function unlock() {
68 \delete_option( 'activitypub_migration_lock' );
69 }
70
71 /**
72 * Whether the database migration process is locked.
73 *
74 * @return boolean
75 */
76 public static function is_locked() {
77 $lock = \get_option( 'activitypub_migration_lock' );
78
79 if ( ! $lock ) {
80 return false;
81 }
82
83 $lock = (int) $lock;
84
85 if ( $lock < \time() - 1800 ) {
86 self::unlock();
87 return false;
88 }
89
90 return true;
91 }
92
93 /**
94 * Whether the database structure is up to date.
95 *
96 * @return bool True if the database structure is up to date, false otherwise.
97 */
98 public static function is_latest_version() {
99 return (bool) \version_compare(
100 self::get_version(),
101 ACTIVITYPUB_PLUGIN_VERSION,
102 '=='
103 );
104 }
105
106 /**
107 * Updates the database structure if necessary.
108 */
109 public static function maybe_migrate() {
110 if ( self::is_latest_version() ) {
111 return;
112 }
113
114 if ( self::is_locked() ) {
115 return;
116 }
117
118 self::lock();
119
120 $version_from_db = self::get_version();
121
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', '<' ) ) {
129 self::migrate_from_0_16();
130 }
131 if ( \version_compare( $version_from_db, '1.0.0', '<' ) ) {
132 \wp_schedule_single_event( \time(), 'activitypub_migrate_from_0_17' );
133 }
134 if ( \version_compare( $version_from_db, '1.3.0', '<' ) ) {
135 self::migrate_from_1_2_0();
136 }
137 if ( \version_compare( $version_from_db, '2.1.0', '<' ) ) {
138 self::migrate_from_2_0_0();
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 }
181
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 }
186
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 }
191
192 if ( \version_compare( $version_from_db, '7.0.0', '<' ) ) {
193 wp_unschedule_hook( 'activitypub_update_followers' );
194 wp_unschedule_hook( 'activitypub_cleanup_followers' );
195
196 if ( ! \wp_next_scheduled( 'activitypub_update_remote_actors' ) ) {
197 \wp_schedule_event( time(), 'hourly', 'activitypub_update_remote_actors' );
198 }
199
200 if ( ! \wp_next_scheduled( 'activitypub_cleanup_remote_actors' ) ) {
201 \wp_schedule_event( time(), 'daily', 'activitypub_cleanup_remote_actors' );
202 }
203 }
204
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();
244 }
245
246 /**
247 * Updates the custom template to use shortcodes instead of the deprecated templates.
248 */
249 private static function migrate_from_0_16() {
250 // Get the custom template.
251 $old_content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
252
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 */
257 $need_update = false;
258
259 // If the old contents is blank, use the defaults.
260 if ( '' === $old_content ) {
261 $old_content = ACTIVITYPUB_CUSTOM_POST_CONTENT;
262 $need_update = true;
263 }
264
265 // Set the new content to be the old content.
266 $content = $old_content;
267
268 // Convert old templates to shortcodes.
269 $content = \str_replace( '%title%', '[ap_title]', $content );
270 $content = \str_replace( '%excerpt%', '[ap_excerpt]', $content );
271 $content = \str_replace( '%content%', '[ap_content]', $content );
272 $content = \str_replace( '%permalink%', '[ap_permalink type="html"]', $content );
273 $content = \str_replace( '%shortlink%', '[ap_shortlink type="html"]', $content );
274 $content = \str_replace( '%hashtags%', '[ap_hashtags]', $content );
275 $content = \str_replace( '%tags%', '[ap_hashtags]', $content );
276
277 // Store the new template if required.
278 if ( $content !== $old_content || $need_update ) {
279 \update_option( 'activitypub_custom_post_content', $content );
280 }
281 }
282
283 /**
284 * Updates the DB-schema of the followers-list.
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 */
304 private static function migrate_from_1_2_0() {
305 $user_ids = \get_users(
306 array(
307 'fields' => 'ID',
308 'capability__in' => array( 'publish_posts' ),
309 )
310 );
311
312 foreach ( $user_ids as $user_id ) {
313 wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' );
314 }
315 }
316
317 /**
318 * Unschedule Hooks after updating to 2.0.0.
319 */
320 private static function migrate_from_2_0_0() {
321 wp_clear_scheduled_hook( 'activitypub_send_post_activity' );
322 wp_clear_scheduled_hook( 'activitypub_send_update_activity' );
323 wp_clear_scheduled_hook( 'activitypub_send_delete_activity' );
324
325 wp_unschedule_hook( 'activitypub_send_post_activity' );
326 wp_unschedule_hook( 'activitypub_send_update_activity' );
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;
1158 }
1159 }
1160