PluginProbe
ActivityPub / 7.8.0
ActivityPub v7.8.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 / wp-admin / import / class-mastodon.php

class-mastodon.php in ActivityPub 7.8.0, at includes/wp-admin/import/class-mastodon.php

633 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Mastodon importer file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\WP_Admin\Import;
9
10 use Activitypub\Attachments;
11
12 use function Activitypub\is_activity_public;
13
14 /**
15 * Mastodon importer class.
16 */
17 class Mastodon {
18
19 /**
20 * Import file attachment ID.
21 *
22 * @var int
23 */
24 private static $import_id;
25
26 /**
27 * Archive folder.
28 *
29 * @var string
30 */
31 private static $archive;
32
33 /**
34 * Outbox file.
35 *
36 * @var array
37 */
38 private static $outbox;
39
40 /**
41 * Author ID.
42 *
43 * @var int
44 */
45 private static $author;
46
47 /**
48 * Whether to fetch attachments.
49 *
50 * @var bool
51 */
52 private static $fetch_attachments;
53
54 /**
55 * Dispatch
56 */
57 public static function dispatch() {
58 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
59 $step = \absint( $_GET['step'] ?? 0 );
60
61 self::header();
62
63 switch ( $step ) {
64 case 0:
65 self::greet();
66 break;
67
68 case 1:
69 \check_admin_referer( 'import-upload' );
70 if ( self::handle_upload() ) {
71 self::import_options();
72 }
73 break;
74
75 case 2:
76 \check_admin_referer( 'import-mastodon' );
77 self::$import_id = \absint( $_POST['import_id'] ?? 0 );
78 self::$author = \absint( $_POST['author'] ?? \get_current_user_id() );
79 self::$fetch_attachments = ! empty( $_POST['fetch_attachments'] );
80
81 \set_time_limit( 0 );
82 self::import();
83 break;
84 }
85
86 self::footer();
87 }
88
89 /**
90 * Handle upload.
91 *
92 * @return bool
93 */
94 public static function handle_upload() {
95 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
96
97 \check_admin_referer( 'import-upload' );
98
99 if ( ! isset( $_FILES['import']['name'] ) ) {
100 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
101 \printf(
102 /* translators: 1: php.ini, 2: post_max_size, 3: upload_max_filesize */
103 \esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your %1$s file or by %2$s being defined as smaller than %3$s in %1$s.', 'activitypub' ),
104 'php.ini',
105 'post_max_size',
106 'upload_max_filesize'
107 );
108 echo '</p>';
109 return false;
110 }
111
112 $file_info = \wp_check_filetype( sanitize_file_name( $_FILES['import']['name'] ), array( 'zip' => 'application/zip' ) );
113 if ( 'application/zip' !== $file_info['type'] ) {
114 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
115 \esc_html_e( 'The uploaded file must be a ZIP archive. Please try again with the correct file format.', 'activitypub' );
116 echo '</p>';
117 return false;
118 }
119
120 $overrides = array(
121 'test_form' => false,
122 'test_type' => false,
123 );
124
125 $upload = wp_handle_upload( $_FILES['import'], $overrides );
126
127 if ( isset( $upload['error'] ) ) {
128 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
129 echo \esc_html( $upload['error'] ) . '</p>';
130 return false;
131 }
132
133 // Construct the attachment array.
134 $attachment = array(
135 'post_title' => wp_basename( $upload['file'] ),
136 'post_content' => $upload['url'],
137 'post_mime_type' => $upload['type'],
138 'guid' => $upload['url'],
139 'context' => 'import',
140 'post_status' => 'private',
141 );
142
143 // Save the data.
144 self::$import_id = wp_insert_attachment( $attachment, $upload['file'] );
145
146 // Schedule a cleanup for one day from now in case of failed import or missing wp_import_cleanup() call.
147 wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( self::$import_id ) );
148
149 return true;
150 }
151
152 /**
153 * Import options.
154 */
155 public static function import_options() {
156 $author = 0;
157 if ( isset( self::$outbox['orderedItems'][0] ) ) {
158 $users = \get_users(
159 array(
160 'fields' => 'ID',
161 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
162 'meta_query' => array(
163 array(
164 'key' => $GLOBALS['wpdb']->get_blog_prefix() . 'activitypub_also_known_as',
165 'value' => self::$outbox['orderedItems'][0]['actor'],
166 'compare' => 'LIKE',
167 ),
168 ),
169 )
170 );
171
172 if ( ! empty( $users ) ) {
173 $author = $users[0];
174 }
175 }
176
177 ?>
178 <form action="<?php echo \esc_url( \admin_url( 'admin.php?import=mastodon&amp;step=2' ) ); ?>" method="post">
179 <?php \wp_nonce_field( 'import-mastodon' ); ?>
180 <input type="hidden" name="import_id" value="<?php echo esc_attr( self::$import_id ); ?>" />
181 <h3><?php \esc_html_e( 'Assign Author', 'activitypub' ); ?></h3>
182 <p>
183 <label for="author"><?php \esc_html_e( 'Author:', 'activitypub' ); ?></label>
184 <?php
185 \wp_dropdown_users(
186 array(
187 'name' => 'author',
188 'id' => 'author',
189 'show' => 'display_name_with_login',
190 'selected' => $author,
191 'capability' => 'activitypub',
192 )
193 );
194 ?>
195 </p>
196 <h3><?php \esc_html_e( 'Import Attachments', 'activitypub' ); ?></h3>
197 <p>
198 <input type="checkbox" value="1" name="fetch_attachments" id="import-attachments" checked />
199 <label for="import-attachments"><?php \esc_html_e( 'Download and import file attachments', 'activitypub' ); ?></label>
200 </p>
201 <p class="submit">
202 <input type="submit" class="button button-primary" value="<?php \esc_attr_e( 'Import', 'activitypub' ); ?>" />
203 </p>
204 </form>
205 <?php
206 }
207
208 /**
209 * Import.
210 */
211 public static function import() {
212 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
213 $file = \get_attached_file( self::$import_id );
214
215 \WP_Filesystem();
216
217 global $wp_filesystem;
218 $import_folder = $wp_filesystem->wp_content_dir() . 'import/';
219 self::$archive = $import_folder . \basename( \basename( $file, '.txt' ), '.zip' );
220
221 // Clean up working directory.
222 if ( $wp_filesystem->is_dir( self::$archive ) ) {
223 $wp_filesystem->delete( self::$archive, true );
224 }
225
226 // Unzip package to working directory.
227 \unzip_file( $file, self::$archive );
228 self::maybe_unwrap_archive();
229
230 if ( ! $wp_filesystem->exists( self::$archive . '/outbox.json' ) ) {
231 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
232 echo \esc_html__( 'The archive does not contain an Outbox file, please try again.', 'activitypub' ) . '</p>';
233 return;
234 }
235
236 self::$outbox = \json_decode( $wp_filesystem->get_contents( self::$archive . '/outbox.json' ), true );
237
238 \wp_suspend_cache_invalidation();
239 \wp_defer_term_counting( true );
240 \wp_defer_comment_counting( true );
241
242 /**
243 * Fires when the Mastodon import starts.
244 */
245 \do_action( 'import_start' );
246
247 $result = self::import_posts();
248
249 \wp_suspend_cache_invalidation( false );
250 \wp_defer_term_counting( false );
251 \wp_defer_comment_counting( false );
252
253 $wp_filesystem->delete( $import_folder, true );
254 \wp_import_cleanup( self::$import_id );
255
256 if ( \is_wp_error( $result ) ) {
257 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
258 echo \esc_html( $result->get_error_message() ) . '</p>';
259 } else {
260 echo '<p>';
261 /* translators: Home URL */
262 \printf( \wp_kses_post( \__( 'All done. <a href="%s">Have fun!</a>', 'activitypub' ) ), \esc_url( \admin_url() ) );
263 echo '</p>';
264 }
265
266 /**
267 * Fires when the Mastodon import ends.
268 */
269 \do_action( 'import_end' );
270 }
271
272 /**
273 * Process posts.
274 *
275 * Uses a multi-pass approach:
276 * 1. Categorize posts into regular posts and self-replies.
277 * 2. Import regular posts (root posts and external replies) as WordPress posts.
278 * 3. Import self-replies as comments on their parent posts.
279 *
280 * @return true|\WP_Error True on success, WP_Error on failure.
281 */
282 public static function import_posts() {
283 $skipped = array();
284 $imported = 0;
285
286 // Pass 1: Categorize posts.
287 $posts_to_import = array();
288 $self_replies = array();
289
290 foreach ( self::$outbox['orderedItems'] as $post ) {
291 // Skip boosts.
292 if ( 'Announce' === $post['type'] ) {
293 continue;
294 }
295
296 if ( ! is_activity_public( $post ) ) {
297 continue;
298 }
299
300 if ( self::is_self_reply( $post ) ) {
301 $self_replies[] = $post;
302 } else {
303 // Root posts and external replies are imported as WordPress posts.
304 $posts_to_import[] = $post;
305 }
306 }
307
308 // Pass 2: Import regular posts as WordPress posts.
309 $source_to_post_id = array();
310 foreach ( $posts_to_import as $post ) {
311 $result = self::import_as_post( $post );
312
313 if ( \is_wp_error( $result ) ) {
314 return $result;
315 }
316
317 if ( $result ) {
318 $source_to_post_id[ $post['object']['id'] ] = $result;
319 ++$imported;
320 } else {
321 $skipped[] = $post['object']['id'];
322 }
323 }
324
325 // Pass 3: Import self-replies as comments (sorted by date for correct threading).
326 \usort(
327 $self_replies,
328 static function ( $a, $b ) {
329 return \strtotime( $a['published'] ) <=> \strtotime( $b['published'] );
330 }
331 );
332
333 $source_to_comment_id = array();
334 $comments_skipped = array();
335 $comments_imported = 0;
336
337 foreach ( $self_replies as $post ) {
338 $result = self::import_as_comment( $post, $source_to_post_id, $source_to_comment_id );
339
340 if ( $result ) {
341 ++$comments_imported;
342 } else {
343 $comments_skipped[] = $post['object']['id'];
344 }
345 }
346
347 // Output results.
348 if ( ! empty( $skipped ) ) {
349 echo '<p>' . \esc_html__( 'Skipped posts:', 'activitypub' ) . '<br>';
350 echo \wp_kses( \implode( '<br>', $skipped ), array( 'br' => array() ) );
351 echo '</p>';
352 }
353
354 if ( ! empty( $comments_skipped ) ) {
355 echo '<p>' . \esc_html__( 'Skipped comments:', 'activitypub' ) . '<br>';
356 echo \wp_kses( \implode( '<br>', $comments_skipped ), array( 'br' => array() ) );
357 echo '</p>';
358 }
359
360 /* translators: %s: Number of posts */
361 echo '<p>' . \esc_html( \sprintf( \_n( 'Imported %s post.', 'Imported %s posts.', $imported, 'activitypub' ), \number_format_i18n( $imported ) ) ) . '</p>';
362
363 if ( $comments_imported > 0 ) {
364 /* translators: %s: Number of comments */
365 echo '<p>' . \esc_html( \sprintf( \_n( 'Imported %s comment from self-reply threads.', 'Imported %s comments from self-reply threads.', $comments_imported, 'activitypub' ), \number_format_i18n( $comments_imported ) ) ) . '</p>';
366 }
367
368 return true;
369 }
370
371 /**
372 * Check if a post is a self-reply (thread continuation).
373 *
374 * A self-reply is when a user replies to their own post, creating a thread.
375 *
376 * @param array $post The Mastodon activity.
377 *
378 * @return bool True if replying to own post.
379 */
380 private static function is_self_reply( $post ) {
381 if ( empty( $post['object']['inReplyTo'] ) ) {
382 return false;
383 }
384
385 /*
386 * Compare base URLs (actor URL should be a prefix of inReplyTo for self-replies).
387 *
388 * Example:
389 * - actor: https://mastodon.social/users/example
390 * - inReplyTo: https://mastodon.social/users/example/statuses/123
391 *
392 * Adding a trailing slash ensures we don't match partial usernames
393 * (e.g., "example" shouldn't match "example2").
394 */
395 return \str_starts_with( $post['object']['inReplyTo'], \rtrim( $post['actor'], '/' ) . '/' );
396 }
397
398 /**
399 * Import a single activity as a WordPress post.
400 *
401 * @param array $post The Mastodon activity.
402 *
403 * @return int|false|\WP_Error Post ID on success, false if skipped, WP_Error on failure.
404 */
405 private static function import_as_post( $post ) {
406 $post_data = array(
407 'post_author' => self::$author,
408 'post_date' => $post['published'],
409 'post_excerpt' => $post['object']['summary'] ?? '',
410 'post_content' => $post['object']['content'],
411 'post_status' => 'publish',
412 'post_type' => 'post',
413 'meta_input' => array( '_source_id' => $post['object']['id'] ),
414 'tags_input' => \array_map(
415 static function ( $tag ) {
416 if ( 'Hashtag' === $tag['type'] ) {
417 return \ltrim( $tag['name'], '#' );
418 }
419
420 return '';
421 },
422 $post['object']['tag'] ?? array()
423 ),
424 );
425
426 /**
427 * Filter the post data before inserting it into the database.
428 *
429 * @param array $post_data The post data to be inserted.
430 * @param array $post The Mastodon Create activity.
431 */
432 $post_data = \apply_filters( 'activitypub_import_mastodon_post_data', $post_data, $post );
433
434 $post_exists = \post_exists( '', $post_data['post_content'], $post_data['post_date'], $post_data['post_type'] );
435
436 /**
437 * Filter ID of the existing post corresponding to post currently importing.
438 *
439 * Return 0 to force the post to be imported. Filter the ID to be something else
440 * to override which existing post is mapped to the imported post.
441 *
442 * @see post_exists()
443 *
444 * @param int $post_exists Post ID, or 0 if post did not exist.
445 * @param array $post_data The post array to be inserted.
446 */
447 $post_exists = \apply_filters( 'wp_import_existing_post', $post_exists, $post_data );
448
449 if ( $post_exists ) {
450 return false;
451 }
452
453 $post_id = \wp_insert_post( $post_data, true );
454
455 if ( \is_wp_error( $post_id ) ) {
456 return $post_id;
457 }
458
459 \set_post_format( $post_id, 'status' );
460
461 // Process attachments if enabled.
462 if ( self::$fetch_attachments && ! empty( $post['object']['attachment'] ) ) {
463 // Prepend archive path to attachment URLs for local files.
464 $attachments = \array_map( array( self::class, 'prepend_archive_path' ), $post['object']['attachment'] );
465
466 Attachments::import( $attachments, $post_id, self::$author );
467 }
468
469 return $post_id;
470 }
471
472 /**
473 * Import a self-reply as a comment on its parent post.
474 *
475 * @param array $post The Mastodon activity.
476 * @param array $source_to_post_id Mapping of source IDs to WordPress post IDs.
477 * @param array $source_to_comment_id Mapping of source IDs to WordPress comment IDs (passed by reference).
478 *
479 * @return int|false Comment ID on success, false if parent not found or skipped.
480 */
481 private static function import_as_comment( $post, $source_to_post_id, &$source_to_comment_id ) {
482 $in_reply_to = $post['object']['inReplyTo'];
483
484 // Find parent - could be a post or another comment.
485 $parent_post_id = null;
486 $parent_comment_id = 0;
487
488 if ( isset( $source_to_post_id[ $in_reply_to ] ) ) {
489 // Replying to a root post or external reply.
490 $parent_post_id = $source_to_post_id[ $in_reply_to ];
491 } elseif ( isset( $source_to_comment_id[ $in_reply_to ] ) ) {
492 // Replying to another comment (nested thread).
493 $parent_comment_id = $source_to_comment_id[ $in_reply_to ];
494 $parent_comment = \get_comment( $parent_comment_id );
495
496 if ( $parent_comment ) {
497 $parent_post_id = $parent_comment->comment_post_ID;
498 }
499 }
500
501 // If we couldn't find the parent, skip this comment.
502 if ( ! $parent_post_id ) {
503 return false;
504 }
505
506 // Check for duplicate.
507 $existing_comments = \get_comments(
508 array(
509 'post_id' => $parent_post_id,
510 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
511 'meta_value' => $post['object']['id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
512 'number' => 1,
513 )
514 );
515
516 if ( ! empty( $existing_comments ) ) {
517 // Already imported, add to mapping and skip.
518 $source_to_comment_id[ $post['object']['id'] ] = $existing_comments[0]->comment_ID;
519
520 return false;
521 }
522
523 $comment_data = array(
524 'comment_post_ID' => $parent_post_id,
525 'comment_parent' => $parent_comment_id,
526 'comment_author' => \get_the_author_meta( 'display_name', self::$author ),
527 'comment_content' => $post['object']['content'],
528 'comment_date' => $post['published'],
529 'user_id' => self::$author,
530 'comment_approved' => 1,
531 );
532
533 $comment_id = \wp_insert_comment( $comment_data );
534
535 if ( $comment_id ) {
536 \update_comment_meta( $comment_id, 'source_id', $post['object']['id'] );
537
538 $source_to_comment_id[ $post['object']['id'] ] = $comment_id;
539 }
540
541 return $comment_id;
542 }
543
544 /**
545 * Header.
546 */
547 public static function header() {
548 echo '<div class="wrap">';
549 echo '<h2>' . \esc_html__( 'Import from Mastodon (Beta)', 'activitypub' ) . '</h2>';
550 }
551
552 /**
553 * Footer.
554 */
555 public static function footer() {
556 echo '</div>';
557 }
558
559 /**
560 * Intro.
561 */
562 public static function greet() {
563 echo '<div class="narrow">';
564 echo '<p>' . \wp_kses(
565 \sprintf(
566 /* translators: %s: URL to Mastodon export documentation */
567 \__( 'This importer allows you to bring your Mastodon posts into your WordPress site. For a smooth import experience, check out the <a href="%s" target="_blank">Mastodon documentation</a>.', 'activitypub' ),
568 'https://docs.joinmastodon.org/user/moving/#export'
569 ),
570 array(
571 'a' => array(
572 'href' => array(),
573 'target' => array(),
574 ),
575 )
576 ) . '</p>';
577 echo '<p>' . \esc_html__( 'Here&#8217;s how to get started:', 'activitypub' ) . '</p>';
578
579 echo '<ol>';
580 echo '<li>' . \wp_kses( \__( 'Log in to your Mastodon account and go to <strong>Preferences > Import and Export</strong>.', 'activitypub' ), array( 'strong' => array() ) ) . '</li>';
581 echo '<li>' . \esc_html__( 'Request a new archive of your data and wait for the email notification.', 'activitypub' ) . '</li>';
582 echo '<li>' . \wp_kses( \__( 'Download the archive file (it will be a <code>.zip</code> file).', 'activitypub' ), array( 'code' => array() ) ) . '</li>';
583 echo '<li>' . \esc_html__( 'Upload that file below to begin the import process.', 'activitypub' ) . '</li>';
584 echo '</ol>';
585
586 \wp_import_upload_form( 'admin.php?import=mastodon&amp;step=1' );
587 echo '</div>';
588 }
589
590 /**
591 * Prepend archive path to local attachment URLs.
592 *
593 * @param array $attachment The attachment array.
594 *
595 * @return array The attachment array with updated URL.
596 */
597 private static function prepend_archive_path( $attachment ) {
598 if ( ! empty( $attachment['url'] ) && ! preg_match( '#^https?://#i', $attachment['url'] ) ) {
599 $attachment['url'] = self::$archive . $attachment['url'];
600 }
601
602 return $attachment;
603 }
604
605 /**
606 * Detect and unwrap single nested directory in archive.
607 *
608 * Some Mastodon exports wrap all files in a root folder. This method
609 * detects this pattern and updates the archive path to point inside it.
610 */
611 private static function maybe_unwrap_archive() {
612 global $wp_filesystem;
613
614 $files = $wp_filesystem->dirlist( self::$archive );
615
616 // Check if there's exactly one directory at root level.
617 if ( count( $files ) !== 1 ) {
618 return;
619 }
620
621 $first = reset( $files );
622 if ( 'd' !== $first['type'] ) {
623 return;
624 }
625
626 // Check if outbox.json exists inside the nested directory.
627 $nested_path = self::$archive . '/' . $first['name'];
628 if ( $wp_filesystem->exists( $nested_path . '/outbox.json' ) ) {
629 self::$archive = $nested_path;
630 }
631 }
632 }
633