PluginProbe
ActivityPub / 2.0.1
ActivityPub v2.0.1
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 / transformer / class-post.php

class-post.php in ActivityPub 2.0.1, at includes/transformer/class-post.php

687 lines 18.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Activitypub\Transformer;
3
4 use WP_Post;
5 use Activitypub\Shortcodes;
6 use Activitypub\Model\Blog_User;
7 use Activitypub\Transformer\Base;
8 use Activitypub\Collection\Users;
9 use Activitypub\Activity\Base_Object;
10
11 use function Activitypub\esc_hashtag;
12 use function Activitypub\is_single_user;
13 use function Activitypub\get_rest_url_by_path;
14 use function Activitypub\site_supports_blocks;
15
16 /**
17 * WordPress Post Transformer
18 *
19 * The Post Transformer is responsible for transforming a WP_Post object into different other
20 * Object-Types.
21 *
22 * Currently supported are:
23 *
24 * - Activitypub\Activity\Base_Object
25 */
26 class Post extends Base {
27 /**
28 * Returns the ID of the WordPress Post.
29 *
30 * @return int The ID of the WordPress Post
31 */
32 public function get_wp_user_id() {
33 return $this->wp_object->post_author;
34 }
35
36 /**
37 * Change the User-ID of the WordPress Post.
38 *
39 * @return int The User-ID of the WordPress Post
40 */
41 public function change_wp_user_id( $user_id ) {
42 $this->wp_object->post_author = $user_id;
43
44 return $this;
45 }
46
47 /**
48 * Transforms the WP_Post object to an ActivityPub Object
49 *
50 * @see \Activitypub\Activity\Base_Object
51 *
52 * @return \Activitypub\Activity\Base_Object The ActivityPub Object
53 */
54 public function to_object() {
55 $post = $this->wp_object;
56 $object = parent::to_object();
57
58 $published = \strtotime( $post->post_date_gmt );
59
60 $object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
61
62 $updated = \strtotime( $post->post_modified_gmt );
63
64 if ( $updated > $published ) {
65 $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
66 }
67
68 $object->set_content_map(
69 array(
70 $this->get_locale() => $this->get_content(),
71 )
72 );
73 $path = sprintf( 'users/%d/followers', intval( $post->post_author ) );
74
75 $object->set_to(
76 array(
77 'https://www.w3.org/ns/activitystreams#Public',
78 get_rest_url_by_path( $path ),
79 )
80 );
81
82 return $object;
83 }
84
85 /**
86 * Returns the ID of the Post.
87 *
88 * @return string The Posts ID.
89 */
90 public function get_id() {
91 return $this->get_url();
92 }
93
94 /**
95 * Returns the URL of the Post.
96 *
97 * @return string The Posts URL.
98 */
99 public function get_url() {
100 $post = $this->wp_object;
101
102 if ( 'trash' === get_post_status( $post ) ) {
103 $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true );
104 } else {
105 $permalink = \get_permalink( $post );
106 }
107
108 return \esc_url( $permalink );
109 }
110
111 /**
112 * Returns the User-URL of the Author of the Post.
113 *
114 * If `single_user` mode is enabled, the URL of the Blog-User is returned.
115 *
116 * @return string The User-URL.
117 */
118 protected function get_attributed_to() {
119 $blog_user = new Blog_User();
120
121 if ( is_single_user() ) {
122 return $blog_user->get_url();
123 }
124
125 $user = Users::get_by_id( $this->wp_object->post_author );
126
127 if ( $user && ! is_wp_error( $user ) ) {
128 return $user->get_url();
129 }
130
131 return $blog_user->get_url();
132 }
133
134 /**
135 * Generates all Media Attachments for a Post.
136 *
137 * @return array The Attachments.
138 */
139 protected function get_attachment() {
140 // Once upon a time we only supported images, but we now support audio/video as well.
141 // We maintain the image-centric naming for backwards compatibility.
142 $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) );
143
144 if ( site_supports_blocks() && \has_blocks( $this->wp_object->post_content ) ) {
145 return $this->get_block_attachments( $max_media );
146 }
147
148 return $this->get_classic_editor_images( $max_media );
149 }
150
151 /**
152 * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments.
153 *
154 * @param int $max_media The maximum number of attachments to return.
155 *
156 * @return array The attachments.
157 */
158 protected function get_block_attachments( $max_media ) {
159 // max media can't be negative or zero
160 if ( $max_media <= 0 ) {
161 return array();
162 }
163
164 $id = $this->wp_object->ID;
165
166 $media_ids = array();
167
168 // list post thumbnail first if this post has one
169 if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
170 $media_ids[] = \get_post_thumbnail_id( $id );
171 }
172
173 if ( $max_media > 0 ) {
174 $blocks = \parse_blocks( $this->wp_object->post_content );
175 $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media );
176 }
177
178 return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) );
179 }
180
181 /**
182 * Get image attachments from the classic editor.
183 * This is imperfect as the contained images aren't necessarily the
184 * same as the attachments.
185 *
186 * @param int $max_images The maximum number of images to return.
187 *
188 * @return array The attachment IDs.
189 */
190 protected function get_classic_editor_image_attachments( $max_images ) {
191 // max images can't be negative or zero
192 if ( $max_images <= 0 ) {
193 return array();
194 }
195 $image_ids = array();
196 $query = new \WP_Query(
197 array(
198 'post_parent' => $this->wp_object->ID,
199 'post_status' => 'inherit',
200 'post_type' => 'attachment',
201 'post_mime_type' => 'image',
202 'order' => 'ASC',
203 'orderby' => 'menu_order ID',
204 'posts_per_page' => $max_images,
205 )
206 );
207 foreach ( $query->get_posts() as $attachment ) {
208 if ( ! \in_array( $attachment->ID, $image_ids, true ) ) {
209 $image_ids[] = $attachment->ID;
210 }
211 }
212 return $image_ids;
213 }
214
215 /**
216 * Get image embeds from the classic editor by parsing HTML.
217 *
218 * @param int $max_images The maximum number of images to return.
219 *
220 * @return array The attachment IDs.
221 */
222 protected function get_classic_editor_image_embeds( $max_images ) {
223 // if someone calls that function directly, bail
224 if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) {
225 return array();
226 }
227
228 // max images can't be negative or zero
229 if ( $max_images <= 0 ) {
230 return array();
231 }
232
233 $image_ids = array();
234 $base = \wp_get_upload_dir()['baseurl'];
235 $content = \get_post_field( 'post_content', $this->wp_object );
236 $tags = new \WP_HTML_Tag_Processor( $content );
237
238 // This linter warning is a false positive - we have to
239 // re-count each time here as we modify $image_ids.
240 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
241 while ( $tags->next_tag( 'img' ) && ( \count( $image_ids ) < $max_images ) ) {
242 $src = $tags->get_attribute( 'src' );
243
244 // If the img source is in our uploads dir, get the
245 // associated ID. Note: if there's a -500x500
246 // type suffix, we remove it, but we try the original
247 // first in case the original image is actually called
248 // that. Likewise, we try adding the -scaled suffix for
249 // the case that this is a small version of an image
250 // that was big enough to get scaled down on upload:
251 // https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/
252 if ( null !== $src && \str_starts_with( $src, $base ) ) {
253 $img_id = \attachment_url_to_postid( $src );
254
255 if ( 0 === $img_id ) {
256 $count = 0;
257 $src = preg_replace( '/-(?:\d+x\d+)(\.[a-zA-Z]+)$/', '$1', $src, 1, $count );
258 if ( $count > 0 ) {
259 $img_id = \attachment_url_to_postid( $src );
260 }
261 }
262
263 if ( 0 === $img_id ) {
264 $src = preg_replace( '/(\.[a-zA-Z]+)$/', '-scaled$1', $src );
265 $img_id = \attachment_url_to_postid( $src );
266 }
267
268 if ( 0 !== $img_id ) {
269 if ( ! \in_array( $img_id, $image_ids, true ) ) {
270 $image_ids[] = $img_id;
271 }
272 }
273 }
274 }
275 return $image_ids;
276 }
277
278 /**
279 * Get post images from the classic editor.
280 * Note that audio/video attachments are only supported in the block editor.
281 *
282 * @param int $max_images The maximum number of images to return.
283 *
284 * @return array The attachments.
285 */
286 protected function get_classic_editor_images( $max_images ) {
287 // max images can't be negative or zero
288 if ( $max_images <= 0 ) {
289 return array();
290 }
291
292 $id = $this->wp_object->ID;
293
294 $image_ids = array();
295
296 // list post thumbnail first if this post has one
297 if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
298 $image_ids[] = \get_post_thumbnail_id( $id );
299 }
300
301 if ( \count( $image_ids ) < $max_images ) {
302 if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) {
303 $image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_embeds( $max_images ) );
304 } else {
305 $image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_attachments( $max_images ) );
306 }
307 }
308 // unique then slice as the thumbnail may duplicate another image
309 $image_ids = \array_slice( \array_unique( $image_ids ), 0, $max_images );
310
311 return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) );
312 }
313
314 /**
315 * Recursively get media IDs from blocks.
316 * @param array $blocks The blocks to search for media IDs
317 * @param array $media_ids The media IDs to append new IDs to
318 * @param int $max_media The maximum number of media to return.
319 *
320 * @return array The image IDs.
321 */
322 protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) {
323
324 foreach ( $blocks as $block ) {
325 // recurse into inner blocks
326 if ( ! empty( $block['innerBlocks'] ) ) {
327 $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media );
328 }
329
330 switch ( $block['blockName'] ) {
331 case 'core/image':
332 case 'core/cover':
333 case 'core/audio':
334 case 'core/video':
335 case 'videopress/video':
336 if ( ! empty( $block['attrs']['id'] ) ) {
337 $media_ids[] = $block['attrs']['id'];
338 }
339 break;
340 case 'jetpack/slideshow':
341 case 'jetpack/tiled-gallery':
342 if ( ! empty( $block['attrs']['ids'] ) ) {
343 $media_ids = array_merge( $media_ids, $block['attrs']['ids'] );
344 }
345 break;
346 case 'jetpack/image-compare':
347 if ( ! empty( $block['attrs']['beforeImageId'] ) ) {
348 $media_ids[] = $block['attrs']['beforeImageId'];
349 }
350 if ( ! empty( $block['attrs']['afterImageId'] ) ) {
351 $media_ids[] = $block['attrs']['afterImageId'];
352 }
353 break;
354 }
355
356 // depupe
357 $media_ids = \array_unique( $media_ids );
358
359 // stop doing unneeded work
360 if ( count( $media_ids ) >= $max_media ) {
361 break;
362 }
363 }
364
365 // still need to slice it because one gallery could knock us over the limit
366 return array_slice( $media_ids, 0, $max_media );
367 }
368
369 /**
370 * Converts a WordPress Attachment to an ActivityPub Attachment.
371 *
372 * @param int $id The Attachment ID.
373 *
374 * @return array The ActivityPub Attachment.
375 */
376 public static function wp_attachment_to_activity_attachment( $id ) {
377 $attachment = array();
378 $mime_type = \get_post_mime_type( $id );
379 $mime_type_parts = \explode( '/', $mime_type );
380 // switching on image/audio/video
381 switch ( $mime_type_parts[0] ) {
382 case 'image':
383 $image_size = 'full';
384
385 /**
386 * Filter the image URL returned for each post.
387 *
388 * @param array|false $thumbnail The image URL, or false if no image is available.
389 * @param int $id The attachment ID.
390 * @param string $image_size The image size to retrieve. Set to 'full' by default.
391 */
392 $thumbnail = apply_filters(
393 'activitypub_get_image',
394 self::get_wordpress_attachment( $id, $image_size ),
395 $id,
396 $image_size
397 );
398
399 if ( $thumbnail ) {
400 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
401 $image = array(
402 'type' => 'Image',
403 'url' => $thumbnail[0],
404 'mediaType' => $mime_type,
405 );
406
407 if ( $alt ) {
408 $image['name'] = $alt;
409 }
410 $attachment = $image;
411 }
412 break;
413
414 case 'audio':
415 case 'video':
416 $attachment = array(
417 'type' => 'Document',
418 'mediaType' => $mime_type,
419 'url' => \wp_get_attachment_url( $id ),
420 'name' => \get_the_title( $id ),
421 );
422 $meta = wp_get_attachment_metadata( $id );
423 // height and width for videos
424 if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) {
425 $attachment['width'] = $meta['width'];
426 $attachment['height'] = $meta['height'];
427 }
428 // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail?
429 break;
430 }
431
432 return \apply_filters( 'activitypub_attachment', $attachment, $id );
433 }
434
435 /**
436 * Return details about an image attachment.
437 *
438 * @param int $id The attachment ID.
439 * @param string $image_size The image size to retrieve. Set to 'full' by default.
440 *
441 * @return array|false Array of image data, or boolean false if no image is available.
442 */
443 protected static function get_wordpress_attachment( $id, $image_size = 'full' ) {
444 /**
445 * Hook into the image retrieval process. Before image retrieval.
446 *
447 * @param int $id The attachment ID.
448 * @param string $image_size The image size to retrieve. Set to 'full' by default.
449 */
450 do_action( 'activitypub_get_image_pre', $id, $image_size );
451
452 $image = \wp_get_attachment_image_src( $id, $image_size );
453
454 /**
455 * Hook into the image retrieval process. After image retrieval.
456 *
457 * @param int $id The attachment ID.
458 * @param string $image_size The image size to retrieve. Set to 'full' by default.
459 */
460 do_action( 'activitypub_get_image_post', $id, $image_size );
461
462 return $image;
463 }
464
465 /**
466 * Returns the ActivityStreams 2.0 Object-Type for a Post based on the
467 * settings and the Post-Type.
468 *
469 * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
470 *
471 * @return string The Object-Type.
472 */
473 protected function get_type() {
474 if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
475 return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
476 }
477
478 // Default to Article.
479 $object_type = 'Article';
480 $post_type = \get_post_type( $this->wp_object );
481 switch ( $post_type ) {
482 case 'post':
483 $post_format = \get_post_format( $this->wp_object );
484 switch ( $post_format ) {
485 case 'aside':
486 case 'status':
487 case 'quote':
488 case 'note':
489 $object_type = 'Note';
490 break;
491 case 'gallery':
492 case 'image':
493 $object_type = 'Image';
494 break;
495 case 'video':
496 $object_type = 'Video';
497 break;
498 case 'audio':
499 $object_type = 'Audio';
500 break;
501 default:
502 $object_type = 'Article';
503 break;
504 }
505 break;
506 case 'page':
507 $object_type = 'Page';
508 break;
509 case 'attachment':
510 $mime_type = \get_post_mime_type();
511 $media_type = \preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
512 switch ( $media_type ) {
513 case 'audio':
514 $object_type = 'Audio';
515 break;
516 case 'video':
517 $object_type = 'Video';
518 break;
519 case 'image':
520 $object_type = 'Image';
521 break;
522 }
523 break;
524 default:
525 $object_type = 'Article';
526 break;
527 }
528
529 return $object_type;
530 }
531
532 /**
533 * Returns a list of Mentions, used in the Post.
534 *
535 * @see https://docs.joinmastodon.org/spec/activitypub/#Mention
536 *
537 * @return array The list of Mentions.
538 */
539 protected function get_cc() {
540 $cc = array();
541
542 $mentions = $this->get_mentions();
543 if ( $mentions ) {
544 foreach ( $mentions as $url ) {
545 $cc[] = $url;
546 }
547 }
548
549 return $cc;
550 }
551
552 /**
553 * Returns a list of Tags, used in the Post.
554 *
555 * This includes Hash-Tags and Mentions.
556 *
557 * @return array The list of Tags.
558 */
559 protected function get_tag() {
560 $tags = array();
561
562 $post_tags = \get_the_tags( $this->wp_object->ID );
563 if ( $post_tags ) {
564 foreach ( $post_tags as $post_tag ) {
565 $tag = array(
566 'type' => 'Hashtag',
567 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
568 'name' => esc_hashtag( $post_tag->name ),
569 );
570 $tags[] = $tag;
571 }
572 }
573
574 $mentions = $this->get_mentions();
575 if ( $mentions ) {
576 foreach ( $mentions as $mention => $url ) {
577 $tag = array(
578 'type' => 'Mention',
579 'href' => \esc_url( $url ),
580 'name' => \esc_html( $mention ),
581 );
582 $tags[] = $tag;
583 }
584 }
585
586 return $tags;
587 }
588
589 /**
590 * Returns the content for the ActivityPub Item.
591 *
592 * The content will be generated based on the user settings.
593 *
594 * @return string The content.
595 */
596 protected function get_content() {
597 global $post;
598
599 /**
600 * Provides an action hook so plugins can add their own hooks/filters before AP content is generated.
601 *
602 * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here.
603 *
604 * @param WP_Post $post The post object.
605 */
606 do_action( 'activitypub_before_get_content', $post );
607
608 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
609 $post = $this->wp_object;
610 $content = $this->get_post_content_template();
611
612 // Register our shortcodes just in time.
613 Shortcodes::register();
614 // Fill in the shortcodes.
615 setup_postdata( $post );
616 $content = do_shortcode( $content );
617 wp_reset_postdata();
618
619 $content = \wpautop( $content );
620 $content = \preg_replace( '/[\n\r\t]/', '', $content );
621 $content = \trim( $content );
622
623 $content = \apply_filters( 'activitypub_the_content', $content, $post );
624
625 // Don't need these any more, should never appear in a post.
626 Shortcodes::unregister();
627
628 return $content;
629 }
630
631 /**
632 * Gets the template to use to generate the content of the activitypub item.
633 *
634 * @return string The Template.
635 */
636 protected function get_post_content_template() {
637 $type = \get_option( 'activitypub_post_content_type', 'content' );
638
639 switch ( $type ) {
640 case 'excerpt':
641 $template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
642 break;
643 case 'title':
644 $template = "[ap_title]\n\n[ap_permalink type=\"html\"]";
645 break;
646 case 'content':
647 $template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
648 break;
649 default:
650 $template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
651 break;
652 }
653
654 return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object );
655 }
656
657 /**
658 * Helper function to get the @-Mentions from the post content.
659 *
660 * @return array The list of @-Mentions.
661 */
662 protected function get_mentions() {
663 return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->post_content, $this->wp_object );
664 }
665
666 /**
667 * Returns the locale of the post.
668 *
669 * @return string The locale of the post.
670 */
671 public function get_locale() {
672 $post_id = $this->wp_object->ID;
673 $lang = \strtolower( \strtok( \get_locale(), '_-' ) );
674
675 /**
676 * Filter the locale of the post.
677 *
678 * @param string $lang The locale of the post.
679 * @param int $post_id The post ID.
680 * @param WP_Post $post The post object.
681 *
682 * @return string The filtered locale of the post.
683 */
684 return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object );
685 }
686 }
687