PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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-starter-kit.php

class-starter-kit.php in ActivityPub trunk, at includes/wp-admin/import/class-starter-kit.php

650 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Starter Kit importer file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\WP_Admin\Import;
9
10 use Activitypub\Http;
11
12 use function Activitypub\follow;
13 use function Activitypub\is_user_type_disabled;
14 use function Activitypub\object_to_uri;
15
16 /**
17 * Starter Kit importer class.
18 */
19 class Starter_Kit {
20 /**
21 * Import file attachment ID.
22 *
23 * @var int
24 */
25 private static $import_id;
26
27 /**
28 * Author ID.
29 *
30 * @var int
31 */
32 private static $author;
33
34 /**
35 * Starter Kit file.
36 *
37 * @var string
38 */
39 private static $file;
40
41 /**
42 * Starter Kit JSON.
43 *
44 * @var object
45 */
46 private static $starter_kit;
47
48 /**
49 * Actors to follow.
50 *
51 * @var array
52 */
53 private static $actor_list;
54
55 /**
56 * Blog user filter callback.
57 *
58 * @var callable
59 */
60 private static $blog_user_filter_callback;
61
62 /**
63 * Blog user filter added.
64 *
65 * @var bool
66 */
67 private static $blog_user_filter_added = false;
68
69 /**
70 * Dispatch
71 */
72 public static function dispatch() {
73 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
74 $step = \absint( $_GET['step'] ?? 0 );
75
76 self::header();
77
78 switch ( $step ) {
79 case 0:
80 self::greet();
81 break;
82
83 case 1:
84 \check_admin_referer( 'import-upload' );
85 if ( self::handle_upload() ) {
86 self::import_options();
87 }
88 break;
89
90 case 2:
91 \check_admin_referer( 'import-url' );
92 if ( self::handle_url_import() ) {
93 self::import_options();
94 }
95 break;
96
97 case 3:
98 \check_admin_referer( 'import-starter-kit' );
99 self::$import_id = \absint( $_POST['import_id'] ?? 0 );
100 self::$author = \absint( $_POST['author'] ?? \get_current_user_id() );
101 self::$actor_list = \array_values(
102 \array_filter(
103 \array_map(
104 static function ( $actor ) {
105 $actor = \sanitize_text_field( $actor );
106 $actor = \wp_unslash( $actor );
107 return self::is_valid_actor( $actor ) ? $actor : null;
108 },
109 // phpcs:ignore
110 $_POST['actors'] ?? array()
111 )
112 )
113 );
114
115 \set_time_limit( 0 );
116 self::import();
117 break;
118 }
119
120 self::footer();
121 }
122
123 /**
124 * Handle upload.
125 */
126 public static function handle_upload() {
127 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
128
129 \check_admin_referer( 'import-upload' );
130
131 if ( ! isset( $_FILES['import']['name'] ) ) {
132 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
133 \printf(
134 /* translators: 1: php.ini, 2: post_max_size, 3: upload_max_filesize */
135 \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' ),
136 'php.ini',
137 'post_max_size',
138 'upload_max_filesize'
139 );
140 echo '</p>';
141 return false;
142 }
143
144 $file_info = \wp_check_filetype( \sanitize_file_name( $_FILES['import']['name'] ), array( 'json' => 'application/json' ) );
145 if ( 'application/json' !== $file_info['type'] ) {
146 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'The uploaded file must be a JSON file. Please try again with the correct file format.', 'activitypub' ) );
147 return false;
148 }
149
150 $overrides = array(
151 'test_form' => false,
152 'test_type' => false,
153 );
154
155 $upload = \wp_handle_upload( $_FILES['import'], $overrides );
156
157 if ( isset( $upload['error'] ) ) {
158 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html( $upload['error'] ) );
159 return false;
160 }
161
162 // Construct the attachment array.
163 $attachment = array(
164 'post_title' => \wp_basename( $upload['file'] ),
165 'post_content' => $upload['url'],
166 'post_mime_type' => $upload['type'],
167 'guid' => $upload['url'],
168 'context' => 'import',
169 'post_status' => 'private',
170 );
171
172 // Save the data.
173 self::$import_id = \wp_insert_attachment( $attachment, $upload['file'] );
174
175 // Schedule a cleanup for one day from now in case of failed import or missing wp_import_cleanup() call.
176 \wp_schedule_single_event( \time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( self::$import_id ) );
177
178 return true;
179 }
180
181 /**
182 * Handle URL import.
183 */
184 public static function handle_url_import() {
185 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
186
187 // phpcs:ignore WordPress.Security.NonceVerification.Missing
188 $url = \sanitize_url( \wp_unslash( $_POST['import_url'] ?? '' ) );
189 if ( empty( $url ) ) {
190 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
191 echo \esc_html__( 'Please provide a valid URL.', 'activitypub' ) . '</p>';
192 return false;
193 }
194
195 // Validate URL format.
196 if ( ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
197 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'The provided URL is not valid.', 'activitypub' ) );
198 return false;
199 }
200
201 // Fetch the URL content. Http::get() sends signed requests with the ActivityPub
202 // headers, so starter kits hosted on servers that require Authorized Fetch work too.
203 $response = Http::get(
204 $url,
205 array(
206 'timeout' => 30,
207 'redirection' => 5,
208 // A curated starter-kit collection can exceed Http::get()'s default 1 MiB cap.
209 'limit_response_size' => 25 * MB_IN_BYTES,
210 )
211 );
212
213 if ( \is_wp_error( $response ) ) {
214 // Http::get() reports a failed HTTP response as a WP_Error whose data carries the status code.
215 $status = $response->get_error_data();
216 $status = \is_array( $status ) && ! empty( $status['status'] ) ? (int) $status['status'] : 0;
217 $message = $status
218 /* translators: %d: HTTP response code */
219 ? \sprintf( \__( 'Failed to fetch URL. HTTP response code: %d', 'activitypub' ), $status )
220 : $response->get_error_message();
221
222 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html( $message ) );
223 return false;
224 }
225
226 $body = \wp_remote_retrieve_body( $response );
227 if ( empty( $body ) ) {
228 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'The URL returned empty content.', 'activitypub' ) );
229 return false;
230 }
231
232 // Validate JSON format.
233 $json_data = \json_decode( $body, true );
234 if ( null === $json_data ) {
235 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'The URL does not contain valid JSON data.', 'activitypub' ) );
236 return false;
237 }
238
239 // Create a temporary file to store the JSON content.
240 $upload_dir = \wp_upload_dir();
241 $base_filename = 'starter-kit.json';
242 $unique_filename = \wp_unique_filename( $upload_dir['path'], $base_filename );
243 $temp_file = \trailingslashit( $upload_dir['path'] ) . $unique_filename;
244
245 if ( ! \WP_Filesystem() ) {
246 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'Failed to initialize the WordPress filesystem.', 'activitypub' ) );
247 return false;
248 }
249
250 global $wp_filesystem;
251
252 if ( ! $wp_filesystem || ! \is_a( $wp_filesystem, 'WP_Filesystem_Base' ) ) {
253 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'Failed to initialize the WordPress filesystem.', 'activitypub' ) );
254 return false;
255 }
256 if ( ! $wp_filesystem->put_contents( $temp_file, $body, FS_CHMOD_FILE ) ) {
257 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'Failed to save the downloaded content.', 'activitypub' ) );
258 return false;
259 }
260
261 // Construct the attachment array.
262 $attachment = array(
263 // phpcs:ignore
264 'post_title' => \sanitize_file_name( \basename( \wp_parse_url( $url, PHP_URL_PATH ) ) ) ?: 'starter-kit.json',
265 'post_content' => $url,
266 'post_mime_type' => 'application/json',
267 'guid' => $url,
268 'context' => 'import',
269 'post_status' => 'private',
270 );
271
272 // Save the data.
273 self::$import_id = \wp_insert_attachment( $attachment, $temp_file );
274
275 // Check if the attachment was inserted successfully.
276 if ( \is_wp_error( self::$import_id ) || ! self::$import_id ) {
277 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html__( 'Failed to insert attachment.', 'activitypub' ) );
278 return false;
279 }
280 // Schedule a cleanup for one day from now in case of failed import or missing wp_import_cleanup() call.
281 \wp_schedule_single_event( \time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( self::$import_id ) );
282
283 return true;
284 }
285
286 /**
287 * Import options.
288 */
289 public static function import_options() {
290 self::setup_blog_user_filter();
291
292 $actors = self::get_actor_list();
293 if ( \is_wp_error( $actors ) ) {
294 self::render_error( $actors );
295 return;
296 }
297
298 self::render_import_form( $actors );
299 self::cleanup_blog_user_filter();
300 }
301
302 /**
303 * Setup blog user filter for dropdown.
304 */
305 private static function setup_blog_user_filter() {
306 if ( is_user_type_disabled( 'blog' ) ) {
307 return;
308 }
309
310 self::$blog_user_filter_callback = static function ( $users ) {
311 return \preg_replace(
312 '/<\/select>/',
313 '<option value="0">' . \__( 'Blog User', 'activitypub' ) . '</option></select>',
314 $users
315 );
316 };
317
318 \add_filter( 'wp_dropdown_users', self::$blog_user_filter_callback );
319
320 self::$blog_user_filter_added = true;
321 }
322
323 /**
324 * Cleanup blog user filter.
325 */
326 private static function cleanup_blog_user_filter() {
327 if ( self::$blog_user_filter_callback && self::$blog_user_filter_added ) {
328 \remove_filter( 'wp_dropdown_users', self::$blog_user_filter_callback );
329 self::$blog_user_filter_callback = null;
330 }
331
332 self::$blog_user_filter_added = false;
333 }
334
335 /**
336 * Render error message.
337 *
338 * @param \WP_Error $error The error to render.
339 */
340 private static function render_error( $error ) {
341 \printf(
342 '<p><strong>%s</strong><br />%s</p>',
343 \esc_html__( 'Sorry, there has been an error.', 'activitypub' ),
344 \esc_html( $error->get_error_message() )
345 );
346 }
347
348 /**
349 * Render the import form.
350 *
351 * @param array $actors The actors to render.
352 */
353 private static function render_import_form( $actors ) {
354 ?>
355 <form action="<?php echo \esc_url( \admin_url( 'admin.php?import=starter-kit&amp;step=3' ) ); ?>" method="post">
356 <?php \wp_nonce_field( 'import-starter-kit' ); ?>
357 <input type="hidden" name="import_id" value="<?php echo \esc_attr( self::$import_id ); ?>" />
358
359 <?php self::render_starter_kit_info(); ?>
360 <?php self::render_author_selection(); ?>
361 <?php self::render_actor_selection( $actors ); ?>
362
363 <p class="submit">
364 <input type="submit" class="button button-primary" value="<?php \esc_attr_e( 'Import', 'activitypub' ); ?>" />
365 </p>
366 </form>
367 <?php
368 }
369
370 /**
371 * Render starter kit information.
372 */
373 private static function render_starter_kit_info() {
374 $name = empty( self::$starter_kit['name'] )
375 ? \__( 'Starter Kit', 'activitypub' )
376 : self::$starter_kit['name'];
377
378 echo '<h3>' . \esc_html( $name ) . '</h3>';
379
380 if ( ! empty( self::$starter_kit['image'] ) ) {
381 $image_url = object_to_uri( self::$starter_kit['image'] );
382
383 if ( $image_url ) {
384 \printf(
385 '<img src="%s" style="max-width: 500px;" alt="%s" />',
386 \esc_url( $image_url ),
387 \esc_attr( $name )
388 );
389 }
390 }
391
392 if ( ! empty( self::$starter_kit['summary'] ) ) {
393 echo '<p>' . \esc_html( self::$starter_kit['summary'] ) . '</p>';
394 }
395
396 if ( ! empty( self::$starter_kit['attributedTo'] ) ) {
397 echo \wp_kses_post(
398 \sprintf(
399 'Created by <a href="%1$s" target="_blank">%1$s</a>',
400 \esc_url( self::$starter_kit['attributedTo'] )
401 )
402 );
403 }
404 }
405
406 /**
407 * Render author selection.
408 */
409 private static function render_author_selection() {
410 ?>
411 <h4><?php \esc_html_e( 'Select the author for the imported Starter Kit', 'activitypub' ); ?></h4>
412 <p>
413 <label for="author"><?php \esc_html_e( 'Author:', 'activitypub' ); ?></label>
414 <?php
415 \wp_dropdown_users(
416 array(
417 'name' => 'author',
418 'id' => 'author',
419 'show' => 'display_name_with_login',
420 'selected' => \get_current_user_id(),
421 'capability' => 'activitypub',
422 )
423 );
424 ?>
425 </p>
426 <?php
427 }
428
429 /**
430 * Render actor selection.
431 *
432 * @param array $actors The actors to render.
433 */
434 private static function render_actor_selection( $actors ) {
435 ?>
436 <h4><?php \esc_html_e( 'Select the accounts you want to follow', 'activitypub' ); ?></h4>
437 <ul>
438 <?php foreach ( $actors as $actor ) : ?>
439 <?php
440 $actor_uri = object_to_uri( $actor );
441 $actor_uri = \ltrim( $actor_uri, '@' );
442
443 if ( ! self::is_valid_actor( $actor_uri ) ) {
444 continue;
445 }
446
447 $actor_name = \is_array( $actor ) ? ( $actor['name'] ?? '' ) : '';
448 ?>
449 <li>
450 <label>
451 <input type="checkbox" name="actors[]" value="<?php echo \esc_attr( $actor_uri ); ?>" checked />
452 <?php if ( $actor_name ) : ?>
453 <strong><?php echo \esc_html( $actor_name ); ?></strong>
454 (<?php echo \esc_html( $actor_uri ); ?>)
455 <?php else : ?>
456 <?php echo \esc_html( $actor_uri ); ?>
457 <?php endif; ?>
458 </label>
459 </li>
460 <?php endforeach; ?>
461 </ul>
462 <?php
463 }
464
465 /**
466 * Check if actor URI is valid.
467 *
468 * @param string $actor_uri The actor URI to validate.
469 *
470 * @return bool True if the actor URI is valid, false otherwise.
471 */
472 private static function is_valid_actor( $actor_uri ) {
473 return false !== \filter_var( $actor_uri, FILTER_VALIDATE_URL ) || false !== \filter_var( $actor_uri, FILTER_VALIDATE_EMAIL );
474 }
475
476 /**
477 * Import.
478 */
479 public static function import() {
480 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
481
482 \wp_suspend_cache_invalidation();
483
484 /**
485 * Fires when the Starter Kit import starts.
486 */
487 \do_action( 'import_start' );
488
489 $result = self::follow();
490
491 \wp_suspend_cache_invalidation( false );
492
493 \wp_import_cleanup( self::$import_id );
494
495 if ( \is_wp_error( $result ) ) {
496 \printf( '<p><strong>%s</strong><br />%s</p>', \esc_html( $error_message ), \esc_html( $result->get_error_message() ) );
497 } else {
498 \printf( '<p>%s</p>', \esc_html__( 'All done.', 'activitypub' ) );
499 }
500
501 /**
502 * Fires when the Starter Kit import ends.
503 */
504 \do_action( 'import_end' );
505 }
506
507 /**
508 * Process posts.
509 *
510 * @return true|\WP_Error True on success, WP_Error on failure.
511 */
512 public static function follow() {
513 $skipped = 0;
514 $followed = 0;
515
516 $items = self::$actor_list;
517
518 foreach ( $items as $actor_id ) {
519 $actor_id = object_to_uri( $actor_id );
520 $actor_id = \ltrim( $actor_id, '@' );
521
522 if ( ! \filter_var( $actor_id, FILTER_VALIDATE_URL ) && ! \filter_var( $actor_id, FILTER_VALIDATE_EMAIL ) ) {
523 ++$skipped;
524 continue;
525 }
526
527 $result = follow( $actor_id, self::$author );
528
529 if ( \is_wp_error( $result ) ) {
530 /* translators: %s: Account ID */
531 \printf( '<p>' . \esc_html__( '&#x2717; %s', 'activitypub' ) . '</p>', \esc_html( $actor_id ) );
532 ++$skipped;
533 } else {
534 /* translators: %s: Account ID */
535 \printf( '<p>' . \esc_html__( '&#x2713; %s', 'activitypub' ) . '</p>', \esc_html( $actor_id ) );
536 ++$followed;
537 }
538 }
539
540 echo '<hr />';
541
542 /* translators: %d: Number of followed actors */
543 \printf( '<p>%s</p>', \esc_html( \sprintf( \_n( 'Followed %s Actor.', 'Followed %s Actors.', $followed, 'activitypub' ), \number_format_i18n( $followed ) ) ) );
544 /* translators: %d: Number of skipped items */
545 \printf( '<p>%s</p>', \esc_html( \sprintf( \_n( 'Skipped %s Item.', 'Skipped %s Items.', $skipped, 'activitypub' ), \number_format_i18n( $skipped ) ) ) );
546
547 return true;
548 }
549
550 /**
551 * Intro.
552 */
553 public static function greet() {
554 echo '<div class="narrow">';
555 echo '<p>' . \esc_html__( 'Starter Kits use the ActivityPub protocol with custom extensions to automate tasks such as following accounts, blocking unwanted content, and applying default configurations. The importer will automatically follow every user listed in the kit, helping users connect right away. Support for additional actions and features will be added over time.', 'activitypub' ) . '</p>';
556
557 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
558 $url = isset( $_GET['url'] ) ? \sanitize_text_field( \wp_unslash( $_GET['url'] ) ) : '';
559
560 if ( empty( $url ) ) {
561 // File upload option.
562 \printf( '<h3>%s</h3>', \esc_html__( 'Option 1: Upload a File', 'activitypub' ) );
563 \wp_import_upload_form( 'admin.php?import=starter-kit&amp;step=1' );
564
565 // URL import option.
566 \printf( '<h3>%s</h3>', \esc_html__( 'Option 2: Import from URL', 'activitypub' ) );
567 } else {
568 // URL import option.
569 \printf( '<h3>%s</h3>', \esc_html__( 'Import from URL', 'activitypub' ) );
570 }
571 ?>
572 <form id="import-url-form" method="post" action="<?php echo \esc_url( \admin_url( 'admin.php?import=starter-kit&amp;step=2' ) ); ?>">
573 <?php
574 \wp_nonce_field( 'import-url' );
575 ?>
576 <p>
577 <label for="import_url"><?php \esc_html_e( 'Starter Kit URL:', 'activitypub' ); ?><br />
578 <input type="url" id="import_url" name="import_url" size="50" class="code" placeholder="https://example.com/starter-kit.json" value="<?php echo \esc_attr( $url ); ?>" required />
579 </label>
580 </p>
581 <p class="submit">
582 <input type="submit" name="submit" id="submit" class="button" value="<?php \esc_attr_e( 'Import from URL', 'activitypub' ); ?>" />
583 </p>
584 </form>
585
586 </div>
587 <?php
588 }
589
590 /**
591 * Header.
592 */
593 public static function header() {
594 echo '<div class="wrap">';
595 echo '<h2>' . \esc_html__( 'Import a Fediverse Starter Kit (Beta)', 'activitypub' ) . '</h2>';
596 }
597
598 /**
599 * Footer.
600 */
601 public static function footer() {
602 echo '</div>';
603 }
604
605 /**
606 * Get actor list.
607 */
608 private static function get_actor_list() {
609 $file = \get_attached_file( self::$import_id );
610
611 \WP_Filesystem();
612
613 global $wp_filesystem;
614
615 $file_contents = $wp_filesystem->get_contents( $file );
616 if ( false === $file_contents ) {
617 return new \WP_Error( 'file_not_found', \esc_html__( 'Could not read the uploaded file.', 'activitypub' ) );
618 }
619
620 self::$starter_kit = \json_decode( $file_contents, true );
621 if ( null === self::$starter_kit ) {
622 return new \WP_Error( 'invalid_json', \esc_html__( 'Invalid JSON format in the uploaded file.', 'activitypub' ) );
623 }
624
625 /*
626 * Validate that the type is a Collection-like type.
627 * FeaturedCollection is from the Mastodon FEP draft:
628 * https://github.com/mastodon/featured_collections/pull/1
629 */
630 $type = (array) ( self::$starter_kit['type'] ?? array() );
631 $valid_types = array( 'Collection', 'OrderedCollection', 'FeaturedCollection' );
632
633 if ( ! \array_intersect( $type, $valid_types ) ) {
634 return new \WP_Error( 'invalid_type', \esc_html__( 'The file does not contain a valid Starter Kit Collection.', 'activitypub' ) );
635 }
636
637 $actors = self::$starter_kit['items'] ?? self::$starter_kit['orderedItems'] ?? array();
638
639 // Limit list to 150 actors.
640 // TODO: Make this configurable.
641 $actors = \array_slice( $actors, 0, 150 );
642
643 if ( ! $actors ) {
644 return new \WP_Error( 'empty_actor_list', \esc_html__( 'The uploaded file does not contain any actors.', 'activitypub' ) );
645 }
646
647 return $actors;
648 }
649 }
650