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

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

676 lines 18.0 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 or -scaled
246 // type suffix, we remove it, but we try the original
247 // first in case the original image is actually called
248 // that.
249 if ( null !== $src && \str_starts_with( $src, $base ) ) {
250 $img_id = \attachment_url_to_postid( $src );
251
252 if ( 0 === $img_id ) {
253 $src_repl = preg_replace( '/-(?:\d+x\d+|scaled)(\.[a-zA-Z]+)/', '$1', $src );
254 $img_id = \attachment_url_to_postid( $src_repl );
255 }
256
257 if ( 0 !== $img_id ) {
258 if ( ! \in_array( $img_id, $image_ids, true ) ) {
259 $image_ids[] = $img_id;
260 }
261 }
262 }
263 }
264 return $image_ids;
265 }
266
267 /**
268 * Get post images from the classic editor.
269 * Note that audio/video attachments are only supported in the block editor.
270 *
271 * @param int $max_images The maximum number of images to return.
272 *
273 * @return array The attachments.
274 */
275 protected function get_classic_editor_images( $max_images ) {
276 // max images can't be negative or zero
277 if ( $max_images <= 0 ) {
278 return array();
279 }
280
281 $id = $this->wp_object->ID;
282
283 $image_ids = array();
284
285 // list post thumbnail first if this post has one
286 if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
287 $image_ids[] = \get_post_thumbnail_id( $id );
288 }
289
290 if ( \count( $image_ids ) < $max_images ) {
291 if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) {
292 $image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_embeds( $max_images ) );
293 } else {
294 $image_ids = \array_merge( $image_ids, $this->get_classic_editor_image_attachments( $max_images ) );
295 }
296 }
297 // unique then slice as the thumbnail may duplicate another image
298 $image_ids = \array_slice( \array_unique( $image_ids ), 0, $max_images );
299
300 return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) );
301 }
302
303 /**
304 * Recursively get media IDs from blocks.
305 * @param array $blocks The blocks to search for media IDs
306 * @param array $media_ids The media IDs to append new IDs to
307 * @param int $max_media The maximum number of media to return.
308 *
309 * @return array The image IDs.
310 */
311 protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) {
312
313 foreach ( $blocks as $block ) {
314 // recurse into inner blocks
315 if ( ! empty( $block['innerBlocks'] ) ) {
316 $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media );
317 }
318
319 switch ( $block['blockName'] ) {
320 case 'core/image':
321 case 'core/cover':
322 case 'core/audio':
323 case 'core/video':
324 case 'videopress/video':
325 if ( ! empty( $block['attrs']['id'] ) ) {
326 $media_ids[] = $block['attrs']['id'];
327 }
328 break;
329 case 'jetpack/slideshow':
330 case 'jetpack/tiled-gallery':
331 if ( ! empty( $block['attrs']['ids'] ) ) {
332 $media_ids = array_merge( $media_ids, $block['attrs']['ids'] );
333 }
334 break;
335 case 'jetpack/image-compare':
336 if ( ! empty( $block['attrs']['beforeImageId'] ) ) {
337 $media_ids[] = $block['attrs']['beforeImageId'];
338 }
339 if ( ! empty( $block['attrs']['afterImageId'] ) ) {
340 $media_ids[] = $block['attrs']['afterImageId'];
341 }
342 break;
343 }
344
345 // depupe
346 $media_ids = \array_unique( $media_ids );
347
348 // stop doing unneeded work
349 if ( count( $media_ids ) >= $max_media ) {
350 break;
351 }
352 }
353
354 // still need to slice it because one gallery could knock us over the limit
355 return array_slice( $media_ids, 0, $max_media );
356 }
357
358 /**
359 * Converts a WordPress Attachment to an ActivityPub Attachment.
360 *
361 * @param int $id The Attachment ID.
362 *
363 * @return array The ActivityPub Attachment.
364 */
365 public static function wp_attachment_to_activity_attachment( $id ) {
366 $attachment = array();
367 $mime_type = \get_post_mime_type( $id );
368 $mime_type_parts = \explode( '/', $mime_type );
369 // switching on image/audio/video
370 switch ( $mime_type_parts[0] ) {
371 case 'image':
372 $image_size = 'full';
373
374 /**
375 * Filter the image URL returned for each post.
376 *
377 * @param array|false $thumbnail The image URL, or false if no image is available.
378 * @param int $id The attachment ID.
379 * @param string $image_size The image size to retrieve. Set to 'full' by default.
380 */
381 $thumbnail = apply_filters(
382 'activitypub_get_image',
383 self::get_wordpress_attachment( $id, $image_size ),
384 $id,
385 $image_size
386 );
387
388 if ( $thumbnail ) {
389 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
390 $image = array(
391 'type' => 'Image',
392 'url' => $thumbnail[0],
393 'mediaType' => $mime_type,
394 );
395
396 if ( $alt ) {
397 $image['name'] = $alt;
398 }
399 $attachment = $image;
400 }
401 break;
402
403 case 'audio':
404 case 'video':
405 $attachment = array(
406 'type' => 'Document',
407 'mediaType' => $mime_type,
408 'url' => \wp_get_attachment_url( $id ),
409 'name' => \get_the_title( $id ),
410 );
411 $meta = wp_get_attachment_metadata( $id );
412 // height and width for videos
413 if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) {
414 $attachment['width'] = $meta['width'];
415 $attachment['height'] = $meta['height'];
416 }
417 // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail?
418 break;
419 }
420
421 return \apply_filters( 'activitypub_attachment', $attachment, $id );
422 }
423
424 /**
425 * Return details about an image attachment.
426 *
427 * @param int $id The attachment ID.
428 * @param string $image_size The image size to retrieve. Set to 'full' by default.
429 *
430 * @return array|false Array of image data, or boolean false if no image is available.
431 */
432 protected static function get_wordpress_attachment( $id, $image_size = 'full' ) {
433 /**
434 * Hook into the image retrieval process. Before image retrieval.
435 *
436 * @param int $id The attachment ID.
437 * @param string $image_size The image size to retrieve. Set to 'full' by default.
438 */
439 do_action( 'activitypub_get_image_pre', $id, $image_size );
440
441 $image = \wp_get_attachment_image_src( $id, $image_size );
442
443 /**
444 * Hook into the image retrieval process. After image retrieval.
445 *
446 * @param int $id The attachment ID.
447 * @param string $image_size The image size to retrieve. Set to 'full' by default.
448 */
449 do_action( 'activitypub_get_image_post', $id, $image_size );
450
451 return $image;
452 }
453
454 /**
455 * Returns the ActivityStreams 2.0 Object-Type for a Post based on the
456 * settings and the Post-Type.
457 *
458 * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
459 *
460 * @return string The Object-Type.
461 */
462 protected function get_type() {
463 if ( 'wordpress-post-format' !== \get_option( 'activitypub_object_type', 'note' ) ) {
464 return \ucfirst( \get_option( 'activitypub_object_type', 'note' ) );
465 }
466
467 // Default to Article.
468 $object_type = 'Article';
469 $post_type = \get_post_type( $this->wp_object );
470 switch ( $post_type ) {
471 case 'post':
472 $post_format = \get_post_format( $this->wp_object );
473 switch ( $post_format ) {
474 case 'aside':
475 case 'status':
476 case 'quote':
477 case 'note':
478 $object_type = 'Note';
479 break;
480 case 'gallery':
481 case 'image':
482 $object_type = 'Image';
483 break;
484 case 'video':
485 $object_type = 'Video';
486 break;
487 case 'audio':
488 $object_type = 'Audio';
489 break;
490 default:
491 $object_type = 'Article';
492 break;
493 }
494 break;
495 case 'page':
496 $object_type = 'Page';
497 break;
498 case 'attachment':
499 $mime_type = \get_post_mime_type();
500 $media_type = \preg_replace( '/(\/[a-zA-Z]+)/i', '', $mime_type );
501 switch ( $media_type ) {
502 case 'audio':
503 $object_type = 'Audio';
504 break;
505 case 'video':
506 $object_type = 'Video';
507 break;
508 case 'image':
509 $object_type = 'Image';
510 break;
511 }
512 break;
513 default:
514 $object_type = 'Article';
515 break;
516 }
517
518 return $object_type;
519 }
520
521 /**
522 * Returns a list of Mentions, used in the Post.
523 *
524 * @see https://docs.joinmastodon.org/spec/activitypub/#Mention
525 *
526 * @return array The list of Mentions.
527 */
528 protected function get_cc() {
529 $cc = array();
530
531 $mentions = $this->get_mentions();
532 if ( $mentions ) {
533 foreach ( $mentions as $url ) {
534 $cc[] = $url;
535 }
536 }
537
538 return $cc;
539 }
540
541 /**
542 * Returns a list of Tags, used in the Post.
543 *
544 * This includes Hash-Tags and Mentions.
545 *
546 * @return array The list of Tags.
547 */
548 protected function get_tag() {
549 $tags = array();
550
551 $post_tags = \get_the_tags( $this->wp_object->ID );
552 if ( $post_tags ) {
553 foreach ( $post_tags as $post_tag ) {
554 $tag = array(
555 'type' => 'Hashtag',
556 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
557 'name' => esc_hashtag( $post_tag->name ),
558 );
559 $tags[] = $tag;
560 }
561 }
562
563 $mentions = $this->get_mentions();
564 if ( $mentions ) {
565 foreach ( $mentions as $mention => $url ) {
566 $tag = array(
567 'type' => 'Mention',
568 'href' => \esc_url( $url ),
569 'name' => \esc_html( $mention ),
570 );
571 $tags[] = $tag;
572 }
573 }
574
575 return $tags;
576 }
577
578 /**
579 * Returns the content for the ActivityPub Item.
580 *
581 * The content will be generated based on the user settings.
582 *
583 * @return string The content.
584 */
585 protected function get_content() {
586 global $post;
587
588 /**
589 * Provides an action hook so plugins can add their own hooks/filters before AP content is generated.
590 *
591 * 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.
592 *
593 * @param WP_Post $post The post object.
594 */
595 do_action( 'activitypub_before_get_content', $post );
596
597 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
598 $post = $this->wp_object;
599 $content = $this->get_post_content_template();
600
601 // Register our shortcodes just in time.
602 Shortcodes::register();
603 // Fill in the shortcodes.
604 setup_postdata( $post );
605 $content = do_shortcode( $content );
606 wp_reset_postdata();
607
608 $content = \wpautop( $content );
609 $content = \preg_replace( '/[\n\r\t]/', '', $content );
610 $content = \trim( $content );
611
612 $content = \apply_filters( 'activitypub_the_content', $content, $post );
613
614 // Don't need these any more, should never appear in a post.
615 Shortcodes::unregister();
616
617 return $content;
618 }
619
620 /**
621 * Gets the template to use to generate the content of the activitypub item.
622 *
623 * @return string The Template.
624 */
625 protected function get_post_content_template() {
626 $type = \get_option( 'activitypub_post_content_type', 'content' );
627
628 switch ( $type ) {
629 case 'excerpt':
630 $template = "[ap_excerpt]\n\n[ap_permalink type=\"html\"]";
631 break;
632 case 'title':
633 $template = "[ap_title]\n\n[ap_permalink type=\"html\"]";
634 break;
635 case 'content':
636 $template = "[ap_content]\n\n[ap_permalink type=\"html\"]\n\n[ap_hashtags]";
637 break;
638 default:
639 $template = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
640 break;
641 }
642
643 return apply_filters( 'activitypub_object_content_template', $template, $this->wp_object );
644 }
645
646 /**
647 * Helper function to get the @-Mentions from the post content.
648 *
649 * @return array The list of @-Mentions.
650 */
651 protected function get_mentions() {
652 return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_object->post_content, $this->wp_object );
653 }
654
655 /**
656 * Returns the locale of the post.
657 *
658 * @return string The locale of the post.
659 */
660 public function get_locale() {
661 $post_id = $this->wp_object->ID;
662 $lang = \strtolower( \strtok( \get_locale(), '_-' ) );
663
664 /**
665 * Filter the locale of the post.
666 *
667 * @param string $lang The locale of the post.
668 * @param int $post_id The post ID.
669 * @param WP_Post $post The post object.
670 *
671 * @return string The filtered locale of the post.
672 */
673 return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_object );
674 }
675 }
676