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

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