PluginProbe
ActivityPub / 5.3.1
ActivityPub v5.3.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 5.3.1, at includes/transformer/class-post.php

1,172 lines 31.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress Post Transformer Class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Transformer;
9
10 use WP_Post;
11 use Activitypub\Shortcodes;
12 use Activitypub\Model\Blog;
13 use Activitypub\Collection\Actors;
14 use Activitypub\Collection\Replies;
15 use Activitypub\Collection\Interactions;
16
17 use function Activitypub\esc_hashtag;
18 use function Activitypub\object_to_uri;
19 use function Activitypub\is_single_user;
20 use function Activitypub\get_enclosures;
21 use function Activitypub\get_upload_baseurl;
22 use function Activitypub\get_content_warning;
23 use function Activitypub\get_rest_url_by_path;
24 use function Activitypub\site_supports_blocks;
25 use function Activitypub\generate_post_summary;
26 use function Activitypub\get_content_visibility;
27
28 /**
29 * WordPress Post Transformer.
30 *
31 * The Post Transformer is responsible for transforming a WP_Post object into different other
32 * Object-Types.
33 *
34 * Currently supported are:
35 *
36 * - Activitypub\Activity\Base_Object
37 */
38 class Post extends Base {
39 /**
40 * The User as Actor Object.
41 *
42 * @var \Activitypub\Activity\Actor
43 */
44 private $actor_object = null;
45
46 /**
47 * Transforms the WP_Post object to an ActivityPub Object
48 *
49 * @see \Activitypub\Activity\Base_Object
50 *
51 * @return \Activitypub\Activity\Base_Object The ActivityPub Object
52 */
53 public function to_object() {
54 $post = $this->item;
55 $object = parent::to_object();
56
57 $content_warning = get_content_warning( $post );
58 if ( ! empty( $content_warning ) ) {
59 $object->set_sensitive( true );
60 $object->set_summary( $content_warning );
61 $object->set_summary_map( null );
62 }
63
64 return $object;
65 }
66
67 /**
68 * Get the content visibility.
69 *
70 * @return string The content visibility.
71 */
72 public function get_content_visibility() {
73 if ( ! $this->content_visibility ) {
74 return get_content_visibility( $this->item );
75 }
76
77 return $this->content_visibility;
78 }
79
80 /**
81 * Returns the User-Object of the Author of the Post.
82 *
83 * If `single_user` mode is enabled, the Blog-User is returned.
84 *
85 * @return \Activitypub\Activity\Actor The User-Object.
86 */
87 public function get_actor_object() {
88 if ( $this->actor_object ) {
89 return $this->actor_object;
90 }
91
92 $blog_user = new Blog();
93 $this->actor_object = $blog_user;
94
95 if ( is_single_user() ) {
96 return $blog_user;
97 }
98
99 $user = Actors::get_by_id( $this->item->post_author );
100
101 if ( $user && ! is_wp_error( $user ) ) {
102 $this->actor_object = $user;
103 return $user;
104 }
105
106 return $blog_user;
107 }
108
109 /**
110 * Returns the ID of the Post.
111 *
112 * @return string The Posts ID.
113 */
114 public function get_id() {
115 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
116 $post_id = (int) $this->item->ID;
117
118 if ( $post_id > $last_legacy_id ) {
119 // Generate URI based on post ID.
120 return \add_query_arg( 'p', $post_id, \trailingslashit( \home_url() ) );
121 }
122
123 return $this->get_url();
124 }
125
126 /**
127 * Returns the URL of the Post.
128 *
129 * @return string The Posts URL.
130 */
131 public function get_url() {
132 $post = $this->item;
133
134 switch ( \get_post_status( $post ) ) {
135 case 'trash':
136 $permalink = \get_post_meta( $post->ID, '_activitypub_canonical_url', true );
137 break;
138 case 'draft':
139 // Get_sample_permalink is in wp-admin, not always loaded.
140 if ( ! \function_exists( '\get_sample_permalink' ) ) {
141 require_once ABSPATH . 'wp-admin/includes/post.php';
142 }
143 $sample = \get_sample_permalink( $post->ID );
144 $permalink = \str_replace( array( '%pagename%', '%postname%' ), $sample[1], $sample[0] );
145 break;
146 default:
147 $permalink = \get_permalink( $post );
148 break;
149 }
150
151 return \esc_url( $permalink );
152 }
153
154 /**
155 * Returns the User-URL of the Author of the Post.
156 *
157 * If `single_user` mode is enabled, the URL of the Blog-User is returned.
158 *
159 * @return string The User-URL.
160 */
161 protected function get_attributed_to() {
162 return $this->get_actor_object()->get_id();
163 }
164
165 /**
166 * Returns the featured image as `Image`.
167 *
168 * @return array|null The Image or null if no image is available.
169 */
170 protected function get_image() {
171 $post_id = $this->item->ID;
172
173 // List post thumbnail first if this post has one.
174 if (
175 ! \function_exists( 'has_post_thumbnail' ) ||
176 ! \has_post_thumbnail( $post_id )
177 ) {
178 return null;
179 }
180
181 $id = \get_post_thumbnail_id( $post_id );
182 $image_size = 'large';
183
184 /**
185 * Filter the image URL returned for each post.
186 *
187 * @param array|false $thumbnail The image URL, or false if no image is available.
188 * @param int $id The attachment ID.
189 * @param string $image_size The image size to retrieve. Set to 'large' by default.
190 */
191 $thumbnail = apply_filters(
192 'activitypub_get_image',
193 $this->get_wordpress_attachment( $id, $image_size ),
194 $id,
195 $image_size
196 );
197
198 if ( ! $thumbnail ) {
199 return null;
200 }
201
202 $mime_type = \get_post_mime_type( $id );
203
204 $image = array(
205 'type' => 'Image',
206 'url' => \esc_url( $thumbnail[0] ),
207 'mediaType' => \esc_attr( $mime_type ),
208 );
209
210 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
211 if ( $alt ) {
212 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) );
213 }
214
215 return $image;
216 }
217
218 /**
219 * Returns an Icon, based on the Featured Image with a fallback to the site-icon.
220 *
221 * @return array|null The Icon or null if no icon is available.
222 */
223 protected function get_icon() {
224 $post_id = $this->item->ID;
225
226 // List post thumbnail first if this post has one.
227 if ( \has_post_thumbnail( $post_id ) ) {
228 $id = \get_post_thumbnail_id( $post_id );
229 } else {
230 // Try site_logo, falling back to site_icon, first.
231 $id = get_option( 'site_icon' );
232 }
233
234 if ( ! $id ) {
235 return null;
236 }
237
238 $image_size = 'thumbnail';
239
240 /**
241 * Filter the image URL returned for each post.
242 *
243 * @param array|false $thumbnail The image URL, or false if no image is available.
244 * @param int $id The attachment ID.
245 * @param string $image_size The image size to retrieve. Set to 'large' by default.
246 */
247 $thumbnail = apply_filters(
248 'activitypub_get_image',
249 $this->get_wordpress_attachment( $id, $image_size ),
250 $id,
251 $image_size
252 );
253
254 if ( ! $thumbnail ) {
255 return null;
256 }
257
258 $mime_type = \get_post_mime_type( $id );
259
260 $image = array(
261 'type' => 'Image',
262 'url' => \esc_url( $thumbnail[0] ),
263 'mediaType' => \esc_attr( $mime_type ),
264 );
265
266 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
267 if ( $alt ) {
268 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) );
269 }
270
271 return $image;
272 }
273
274 /**
275 * Generates all Media Attachments for a Post.
276 *
277 * @return array The Attachments.
278 */
279 protected function get_attachment() {
280 // Remove attachments from drafts.
281 if ( 'draft' === \get_post_status( $this->item ) ) {
282 return array();
283 }
284
285 /**
286 * Filters the maximum number of media attachments allowed in a post.
287 *
288 * Despite the name suggesting only images, this filter controls the maximum number
289 * of all media attachments (images, audio, and video) that can be included in an
290 * ActivityPub post. The name is maintained for backwards compatibility.
291 *
292 * @param int $max_media Maximum number of media attachments. Default ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS.
293 */
294 $max_media = \intval(
295 \apply_filters(
296 'activitypub_max_image_attachments',
297 \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS )
298 )
299 );
300
301 $media = array(
302 'image' => array(),
303 'audio' => array(),
304 'video' => array(),
305 );
306 $id = $this->item->ID;
307
308 // List post thumbnail first if this post has one.
309 if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) {
310 $media['image'][] = array( 'id' => \get_post_thumbnail_id( $id ) );
311 }
312
313 $media = $this->get_enclosures( $media );
314
315 if ( site_supports_blocks() && \has_blocks( $this->item->post_content ) ) {
316 $media = $this->get_block_attachments( $media, $max_media );
317 } else {
318 $media = $this->get_classic_editor_images( $media, $max_media );
319 }
320
321 $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->item ), $this->item );
322 $unique_ids = \array_unique( \array_column( $media, 'id' ) );
323 $media = \array_intersect_key( $media, $unique_ids );
324 $media = \array_slice( $media, 0, $max_media );
325
326 /**
327 * Filter the attachment IDs for a post.
328 *
329 * @param array $media The media array grouped by type.
330 * @param WP_Post $this->item The post object.
331 *
332 * @return array The filtered attachment IDs.
333 */
334 $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->item );
335
336 $attachments = \array_filter( \array_map( array( $this, 'wp_attachment_to_activity_attachment' ), $media ) );
337
338 /**
339 * Filter the attachments for a post.
340 *
341 * @param array $attachments The attachments.
342 * @param WP_Post $this->item The post object.
343 *
344 * @return array The filtered attachments.
345 */
346 return \apply_filters( 'activitypub_attachments', $attachments, $this->item );
347 }
348
349 /**
350 * Returns the ActivityStreams 2.0 Object-Type for a Post based on the
351 * settings and the Post-Type.
352 *
353 * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
354 *
355 * @return string The Object-Type.
356 */
357 protected function get_type() {
358 $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
359
360 if ( 'wordpress-post-format' !== $post_format_setting ) {
361 return \ucfirst( $post_format_setting );
362 }
363
364 $has_title = \post_type_supports( $this->item->post_type, 'title' );
365 $content = \wp_strip_all_tags( $this->item->post_content );
366
367 // Check if the post has a title.
368 if (
369 ! $has_title ||
370 ! $this->item->post_title ||
371 \strlen( $content ) <= ACTIVITYPUB_NOTE_LENGTH
372 ) {
373 return 'Note';
374 }
375
376 // Default to Note.
377 $object_type = 'Note';
378 $post_type = \get_post_type( $this->item );
379
380 if ( 'page' === $post_type ) {
381 $object_type = 'Page';
382 } elseif ( ! \get_post_format( $this->item ) ) {
383 $object_type = 'Article';
384 }
385
386 return $object_type;
387 }
388
389 /**
390 * Returns the Audience for the Post.
391 *
392 * @return string|null The audience.
393 */
394 public function get_audience() {
395 $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
396
397 if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) {
398 $blog = new Blog();
399 return $blog->get_id();
400 }
401
402 return null;
403 }
404
405 /**
406 * Returns a list of Tags, used in the Post.
407 *
408 * This includes Hash-Tags and Mentions.
409 *
410 * @return array The list of Tags.
411 */
412 protected function get_tag() {
413 $tags = parent::get_tag();
414
415 $post_tags = \get_the_tags( $this->item->ID );
416 if ( $post_tags ) {
417 foreach ( $post_tags as $post_tag ) {
418 $tag = array(
419 'type' => 'Hashtag',
420 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
421 'name' => esc_hashtag( $post_tag->name ),
422 );
423 $tags[] = $tag;
424 }
425 }
426
427 return \array_unique( $tags, SORT_REGULAR );
428 }
429
430 /**
431 * Returns the summary for the ActivityPub Item.
432 *
433 * The summary will be generated based on the user settings and only if the
434 * object type is not set to `note`.
435 *
436 * @return string|null The summary or null if the object type is `note`.
437 */
438 protected function get_summary() {
439 if ( 'Note' === $this->get_type() ) {
440 return null;
441 }
442
443 // Remove Teaser from drafts.
444 if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) {
445 return \__( '(This post is being modified)', 'activitypub' );
446 }
447
448 return generate_post_summary( $this->item );
449 }
450
451 /**
452 * Returns the title for the ActivityPub Item.
453 *
454 * The title will be generated based on the user settings and only if the
455 * object type is not set to `note`.
456 *
457 * @return string|null The title or null if the object type is `note`.
458 */
459 protected function get_name() {
460 if ( 'Note' === $this->get_type() ) {
461 return null;
462 }
463
464 $title = \get_the_title( $this->item->ID );
465
466 if ( ! $title ) {
467 return null;
468 }
469
470 return \wp_strip_all_tags(
471 \html_entity_decode(
472 $title
473 )
474 );
475 }
476
477 /**
478 * Returns the content for the ActivityPub Item.
479 *
480 * The content will be generated based on the user settings.
481 *
482 * @return string The content.
483 */
484 protected function get_content() {
485 add_filter( 'activitypub_reply_block', '__return_empty_string' );
486
487 // Remove Content from drafts.
488 if ( 'draft' === \get_post_status( $this->item ) ) {
489 return \__( '(This post is being modified)', 'activitypub' );
490 }
491
492 global $post;
493
494 /**
495 * Provides an action hook so plugins can add their own hooks/filters before AP content is generated.
496 *
497 * 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.
498 *
499 * @param WP_Post $post The post object.
500 */
501 do_action( 'activitypub_before_get_content', $post );
502
503 add_filter( 'render_block_core/embed', array( $this, 'revert_embed_links' ), 10, 2 );
504
505 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
506 $post = $this->item;
507 $content = $this->get_post_content_template();
508
509 // It seems that shortcodes are only applied to published posts.
510 if ( is_preview() ) {
511 $post->post_status = 'publish';
512 }
513
514 // Register our shortcodes just in time.
515 Shortcodes::register();
516 // Fill in the shortcodes.
517 \setup_postdata( $post );
518 $content = \do_shortcode( $content );
519 \wp_reset_postdata();
520
521 $content = \wpautop( $content );
522 $content = \preg_replace( '/[\n\r\t]/', '', $content );
523 $content = \trim( $content );
524
525 /**
526 * Filters the post content before it is transformed for ActivityPub.
527 *
528 * @param string $content The post content to be transformed.
529 * @param WP_Post $post The post object being transformed.
530 */
531 $content = \apply_filters( 'activitypub_the_content', $content, $post );
532
533 // Don't need these anymore, should never appear in a post.
534 Shortcodes::unregister();
535
536 return $content;
537 }
538
539 /**
540 * Returns the in-reply-to URL of the post.
541 *
542 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
543 *
544 * @return string|null The in-reply-to URL of the post.
545 */
546 protected function get_in_reply_to() {
547 if ( ! site_supports_blocks() ) {
548 return null;
549 }
550
551 $blocks = \parse_blocks( $this->item->post_content );
552
553 foreach ( $blocks as $block ) {
554 if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) {
555 // We only support one reply block per post for now.
556 return $block['attrs']['url'];
557 }
558 }
559
560 return null;
561 }
562
563 /**
564 * Returns the published date of the post.
565 *
566 * @return string The published date of the post.
567 */
568 protected function get_published() {
569 $published = \strtotime( $this->item->post_date_gmt );
570
571 return \gmdate( 'Y-m-d\TH:i:s\Z', $published );
572 }
573
574 /**
575 * Returns the updated date of the post.
576 *
577 * @return string|null The updated date of the post.
578 */
579 protected function get_updated() {
580 $published = \strtotime( $this->item->post_date_gmt );
581 $updated = \strtotime( $this->item->post_modified_gmt );
582
583 if ( $updated > $published ) {
584 return \gmdate( 'Y-m-d\TH:i:s\Z', $updated );
585 }
586
587 return null;
588 }
589
590 /**
591 * Helper function to extract the @-Mentions from the post content.
592 *
593 * @return array The list of @-Mentions.
594 */
595 protected function get_mentions() {
596 /**
597 * Filter the mentions in the post content.
598 *
599 * @param array $mentions The mentions.
600 * @param string $content The post content.
601 * @param WP_Post $post The post object.
602 *
603 * @return array The filtered mentions.
604 */
605 return apply_filters(
606 'activitypub_extract_mentions',
607 array(),
608 $this->item->post_content . ' ' . $this->item->post_excerpt,
609 $this->item
610 );
611 }
612
613 /**
614 * Transform Embed blocks to block level link.
615 *
616 * Remote servers will simply drop iframe elements, rendering incomplete content.
617 *
618 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
619 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
620 *
621 * @param string $block_content The block content (html).
622 * @param object $block The block object.
623 *
624 * @return string A block level link
625 */
626 public function revert_embed_links( $block_content, $block ) {
627 if ( ! isset( $block['attrs']['url'] ) ) {
628 return $block_content;
629 }
630 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
631 }
632
633 /**
634 * Check if the post is a preview.
635 *
636 * @return boolean True if the post is a preview, false otherwise.
637 */
638 private function is_preview() {
639 return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW;
640 }
641
642 /**
643 * Get enclosures for a post.
644 *
645 * @param array $media The media array grouped by type.
646 *
647 * @return array The media array extended with enclosures.
648 */
649 protected function get_enclosures( $media ) {
650 $enclosures = get_enclosures( $this->item->ID );
651
652 if ( ! $enclosures ) {
653 return $media;
654 }
655
656 foreach ( $enclosures as $enclosure ) {
657 // Check if URL is an attachment.
658 $attachment_id = \attachment_url_to_postid( $enclosure['url'] );
659
660 if ( $attachment_id ) {
661 $enclosure['id'] = $attachment_id;
662 $enclosure['url'] = \wp_get_attachment_url( $attachment_id );
663 $enclosure['mediaType'] = \get_post_mime_type( $attachment_id );
664 }
665
666 $mime_type = $enclosure['mediaType'];
667 $mime_type_parts = \explode( '/', $mime_type );
668 $enclosure['type'] = \ucfirst( $mime_type_parts[0] );
669
670 switch ( $mime_type_parts[0] ) {
671 case 'image':
672 $media['image'][] = $enclosure;
673 break;
674 case 'audio':
675 $media['audio'][] = $enclosure;
676 break;
677 case 'video':
678 $media['video'][] = $enclosure;
679 break;
680 }
681 }
682
683 return $media;
684 }
685
686 /**
687 * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments.
688 *
689 * @param array $media The media array grouped by type.
690 * @param int $max_media The maximum number of attachments to return.
691 *
692 * @return array The attachments.
693 */
694 protected function get_block_attachments( $media, $max_media ) {
695 // Max media can't be negative or zero.
696 if ( $max_media <= 0 ) {
697 return array();
698 }
699
700 $blocks = \parse_blocks( $this->item->post_content );
701
702 return $this->get_media_from_blocks( $blocks, $media );
703 }
704
705 /**
706 * Recursively get media IDs from blocks.
707 *
708 * @param array $blocks The blocks to search for media IDs.
709 * @param array $media The media IDs to append new IDs to.
710 *
711 * @return array The image IDs.
712 */
713 protected function get_media_from_blocks( $blocks, $media ) {
714 foreach ( $blocks as $block ) {
715 // Recurse into inner blocks.
716 if ( ! empty( $block['innerBlocks'] ) ) {
717 $media = $this->get_media_from_blocks( $block['innerBlocks'], $media );
718 }
719
720 switch ( $block['blockName'] ) {
721 case 'core/image':
722 case 'core/cover':
723 if ( ! empty( $block['attrs']['id'] ) ) {
724 $alt = '';
725 $check = preg_match( '/<img.*?alt\s*=\s*([\"\'])(.*?)\1.*>/i', $block['innerHTML'], $match );
726
727 if ( $check ) {
728 $alt = $match[2];
729 }
730
731 $found = false;
732 foreach ( $media['image'] as $i => $image ) {
733 if ( $image['id'] === $block['attrs']['id'] ) {
734 $media['image'][ $i ]['alt'] = $alt;
735 $found = true;
736 break;
737 }
738 }
739
740 if ( ! $found ) {
741 $media['image'][] = array(
742 'id' => $block['attrs']['id'],
743 'alt' => $alt,
744 );
745 }
746 }
747 break;
748 case 'core/audio':
749 if ( ! empty( $block['attrs']['id'] ) ) {
750 $media['audio'][] = array( 'id' => $block['attrs']['id'] );
751 }
752 break;
753 case 'core/video':
754 case 'videopress/video':
755 if ( ! empty( $block['attrs']['id'] ) ) {
756 $media['video'][] = array( 'id' => $block['attrs']['id'] );
757 }
758 break;
759 case 'jetpack/slideshow':
760 case 'jetpack/tiled-gallery':
761 if ( ! empty( $block['attrs']['ids'] ) ) {
762 $media['image'] = array_merge(
763 $media['image'],
764 array_map(
765 function ( $id ) {
766 return array( 'id' => $id );
767 },
768 $block['attrs']['ids']
769 )
770 );
771 }
772 break;
773 case 'jetpack/image-compare':
774 if ( ! empty( $block['attrs']['beforeImageId'] ) ) {
775 $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] );
776 }
777 if ( ! empty( $block['attrs']['afterImageId'] ) ) {
778 $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] );
779 }
780 break;
781 }
782 }
783
784 return $media;
785 }
786
787 /**
788 * Get post images from the classic editor.
789 * Note that audio/video attachments are only supported in the block editor.
790 *
791 * @param array $media The media array grouped by type.
792 * @param int $max_images The maximum number of images to return.
793 *
794 * @return array The attachments.
795 */
796 protected function get_classic_editor_images( $media, $max_images ) {
797 // Max images can't be negative or zero.
798 if ( $max_images <= 0 ) {
799 return array();
800 }
801
802 if ( \count( $media['image'] ) <= $max_images ) {
803 if ( \class_exists( '\WP_HTML_Tag_Processor' ) ) {
804 $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_embeds( $max_images ) );
805 } else {
806 $media['image'] = \array_merge( $media['image'], $this->get_classic_editor_image_attachments( $max_images ) );
807 }
808 }
809
810 return $media;
811 }
812
813 /**
814 * Get image embeds from the classic editor by parsing HTML.
815 *
816 * @param int $max_images The maximum number of images to return.
817 *
818 * @return array The attachments.
819 */
820 protected function get_classic_editor_image_embeds( $max_images ) {
821 // If someone calls that function directly, bail.
822 if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) {
823 return array();
824 }
825
826 // Max images can't be negative or zero.
827 if ( $max_images <= 0 ) {
828 return array();
829 }
830
831 $images = array();
832 $base = get_upload_baseurl();
833 $content = \get_post_field( 'post_content', $this->item );
834 $tags = new \WP_HTML_Tag_Processor( $content );
835
836 // This linter warning is a false positive - we have to re-count each time here as we modify $images.
837 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
838 while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) {
839 /**
840 * Filter the image source URL.
841 *
842 * This can be used to modify the image source URL before it is used to
843 * determine the attachment ID.
844 *
845 * @param string $src The image source URL.
846 */
847 $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) );
848
849 /*
850 * If the img source is in our uploads dir, get the
851 * associated ID. Note: if there's a -500x500
852 * type suffix, we remove it, but we try the original
853 * first in case the original image is actually called
854 * that. Likewise, we try adding the -scaled suffix for
855 * the case that this is a small version of an image
856 * that was big enough to get scaled down on upload:
857 * https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/
858 */
859 if ( null !== $src && \str_starts_with( $src, $base ) ) {
860 $img_id = \attachment_url_to_postid( $src );
861
862 if ( 0 === $img_id ) {
863 $count = 0;
864 $src = \strtok( $src, '?' );
865 $img_id = \attachment_url_to_postid( $src );
866 }
867
868 if ( 0 === $img_id ) {
869 $count = 0;
870 $src = \preg_replace( '/-(?:\d+x\d+)(\.[a-zA-Z]+)$/', '$1', $src, 1, $count );
871 if ( $count > 0 ) {
872 $img_id = \attachment_url_to_postid( $src );
873 }
874 }
875
876 if ( 0 === $img_id ) {
877 $src = \preg_replace( '/(\.[a-zA-Z]+)$/', '-scaled$1', $src );
878 $img_id = \attachment_url_to_postid( $src );
879 }
880
881 if ( 0 !== $img_id ) {
882 $images[] = array(
883 'id' => $img_id,
884 'alt' => $tags->get_attribute( 'alt' ),
885 );
886 }
887 }
888 }
889
890 return $images;
891 }
892
893 /**
894 * Get image attachments from the classic editor.
895 * This is imperfect as the contained images aren't necessarily the
896 * same as the attachments.
897 *
898 * @param int $max_images The maximum number of images to return.
899 *
900 * @return array The attachment IDs.
901 */
902 protected function get_classic_editor_image_attachments( $max_images ) {
903 // Max images can't be negative or zero.
904 if ( $max_images <= 0 ) {
905 return array();
906 }
907
908 $images = array();
909 $query = new \WP_Query(
910 array(
911 'post_parent' => $this->item->ID,
912 'post_status' => 'inherit',
913 'post_type' => 'attachment',
914 'post_mime_type' => 'image',
915 'order' => 'ASC',
916 'orderby' => 'menu_order ID',
917 'posts_per_page' => $max_images,
918 )
919 );
920
921 foreach ( $query->get_posts() as $attachment ) {
922 if ( ! \in_array( $attachment->ID, $images, true ) ) {
923 $images[] = array( 'id' => $attachment->ID );
924 }
925 }
926
927 return $images;
928 }
929
930 /**
931 * Filter media IDs by object type.
932 *
933 * @param array $media The media array grouped by type.
934 * @param string $type The object type.
935 * @param WP_Post $item The post object.
936 *
937 * @return array The filtered media IDs.
938 */
939 protected function filter_media_by_object_type( $media, $type, $item ) {
940 /**
941 * Filter the object type for media attachments.
942 *
943 * @param string $type The object type.
944 * @param WP_Post $item The post object.
945 *
946 * @return string The filtered object type.
947 */
948 $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $item );
949
950 if ( ! empty( $media[ $type ] ) ) {
951 return $media[ $type ];
952 }
953
954 return array_filter( array_merge( ...array_values( $media ) ) );
955 }
956
957 /**
958 * Converts a WordPress Attachment to an ActivityPub Attachment.
959 *
960 * @param array $media The Attachment array.
961 *
962 * @return array The ActivityPub Attachment.
963 */
964 public function wp_attachment_to_activity_attachment( $media ) {
965 if ( ! isset( $media['id'] ) ) {
966 return $media;
967 }
968
969 $id = $media['id'];
970 $attachment = array();
971 $mime_type = \get_post_mime_type( $id );
972 $mime_type_parts = \explode( '/', $mime_type );
973 // Switching on image/audio/video.
974 switch ( $mime_type_parts[0] ) {
975 case 'image':
976 $image_size = 'large';
977
978 /**
979 * Filter the image URL returned for each post.
980 *
981 * @param array|false $thumbnail The image URL, or false if no image is available.
982 * @param int $id The attachment ID.
983 * @param string $image_size The image size to retrieve. Set to 'large' by default.
984 */
985 $thumbnail = apply_filters(
986 'activitypub_get_image',
987 $this->get_wordpress_attachment( $id, $image_size ),
988 $id,
989 $image_size
990 );
991
992 if ( $thumbnail ) {
993 $image = array(
994 'type' => 'Image',
995 'url' => \esc_url( $thumbnail[0] ),
996 'mediaType' => \esc_attr( $mime_type ),
997 );
998
999 if ( ! empty( $media['alt'] ) ) {
1000 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $media['alt'] ) );
1001 } else {
1002 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
1003 if ( $alt ) {
1004 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt ) );
1005 }
1006 }
1007
1008 $attachment = $image;
1009 }
1010 break;
1011
1012 case 'audio':
1013 case 'video':
1014 $attachment = array(
1015 'type' => 'Document',
1016 'mediaType' => \esc_attr( $mime_type ),
1017 'url' => \esc_url( \wp_get_attachment_url( $id ) ),
1018 'name' => \esc_attr( \get_the_title( $id ) ),
1019 );
1020 $meta = wp_get_attachment_metadata( $id );
1021 // Height and width for videos.
1022 if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) {
1023 $attachment['width'] = \esc_attr( $meta['width'] );
1024 $attachment['height'] = \esc_attr( $meta['height'] );
1025 }
1026
1027 if ( $this->get_icon() ) {
1028 $attachment['icon'] = object_to_uri( $this->get_icon() );
1029 }
1030
1031 break;
1032 }
1033
1034 /**
1035 * Filter the attachment for a post.
1036 *
1037 * @param array $attachment The attachment.
1038 * @param int $id The attachment ID.
1039 *
1040 * @return array The filtered attachment.
1041 */
1042 return \apply_filters( 'activitypub_attachment', $attachment, $id );
1043 }
1044
1045 /**
1046 * Return details about an image attachment.
1047 *
1048 * @param int $id The attachment ID.
1049 * @param string $image_size The image size to retrieve. Set to 'large' by default.
1050 *
1051 * @return array|false Array of image data, or boolean false if no image is available.
1052 */
1053 protected function get_wordpress_attachment( $id, $image_size = 'large' ) {
1054 /**
1055 * Hook into the image retrieval process. Before image retrieval.
1056 *
1057 * @param int $id The attachment ID.
1058 * @param string $image_size The image size to retrieve. Set to 'large' by default.
1059 */
1060 do_action( 'activitypub_get_image_pre', $id, $image_size );
1061
1062 $image = \wp_get_attachment_image_src( $id, $image_size );
1063
1064 /**
1065 * Hook into the image retrieval process. After image retrieval.
1066 *
1067 * @param int $id The attachment ID.
1068 * @param string $image_size The image size to retrieve. Set to 'large' by default.
1069 */
1070 do_action( 'activitypub_get_image_post', $id, $image_size );
1071
1072 return $image;
1073 }
1074
1075 /**
1076 * Get the context of the post.
1077 *
1078 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
1079 *
1080 * @return string The context of the post.
1081 */
1082 protected function get_context() {
1083 return get_rest_url_by_path( sprintf( 'posts/%d/context', $this->item->ID ) );
1084 }
1085
1086 /**
1087 * Gets the template to use to generate the content of the activitypub item.
1088 *
1089 * @return string The Template.
1090 */
1091 protected function get_post_content_template() {
1092 $content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
1093 $template = $content ?? ACTIVITYPUB_CUSTOM_POST_CONTENT;
1094
1095 $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
1096
1097 if ( 'wordpress-post-format' === $post_format_setting ) {
1098 $template = '';
1099
1100 if ( 'Note' === $this->get_type() ) {
1101 $template .= "[ap_title type=\"html\"]\n\n";
1102 }
1103
1104 $template .= '[ap_content]';
1105 }
1106
1107 /**
1108 * Filters the template used to generate ActivityPub object content.
1109 *
1110 * This filter allows developers to modify the template that determines how post
1111 * content is formatted in ActivityPub objects. The template can include special
1112 * shortcodes like [ap_title] and [ap_content] that are processed during content
1113 * generation.
1114 *
1115 * @param string $template The template string containing shortcodes.
1116 * @param WP_Post $item The WordPress post object being transformed.
1117 */
1118 return apply_filters( 'activitypub_object_content_template', $template, $this->item );
1119 }
1120
1121 /**
1122 * Get the replies Collection.
1123 *
1124 * @return array|null The replies collection on success or null on failure.
1125 */
1126 public function get_replies() {
1127 return Replies::get_collection( $this->item );
1128 }
1129
1130 /**
1131 * Get the likes Collection.
1132 *
1133 * @return array The likes collection.
1134 */
1135 public function get_likes() {
1136 return array(
1137 'id' => get_rest_url_by_path( sprintf( 'posts/%d/likes', $this->item->ID ) ),
1138 'type' => 'Collection',
1139 'totalItems' => Interactions::count_by_type( $this->item->ID, 'like' ),
1140 );
1141 }
1142
1143 /**
1144 * Get the shares Collection.
1145 *
1146 * @return array The shares collection.
1147 */
1148 public function get_shares() {
1149 return array(
1150 'id' => get_rest_url_by_path( sprintf( 'posts/%d/shares', $this->item->ID ) ),
1151 'type' => 'Collection',
1152 'totalItems' => Interactions::count_by_type( $this->item->ID, 'repost' ),
1153 );
1154 }
1155
1156 /**
1157 * Get the preview of the post.
1158 *
1159 * @return array|null The preview of the post or null if the post is not an Article.
1160 */
1161 public function get_preview() {
1162 if ( 'Article' !== $this->get_type() ) {
1163 return null;
1164 }
1165
1166 return array(
1167 'type' => 'Note',
1168 'content' => $this->get_summary(),
1169 );
1170 }
1171 }
1172