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

1,232 lines 34.3 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 Activitypub\Activity\Base_Object;
11 use Activitypub\Collection\Actors;
12 use Activitypub\Collection\Interactions;
13 use Activitypub\Collection\Replies;
14 use Activitypub\Model\Blog;
15 use Activitypub\Shortcodes;
16
17 use function Activitypub\esc_hashtag;
18 use function Activitypub\generate_post_summary;
19 use function Activitypub\get_content_visibility;
20 use function Activitypub\get_content_warning;
21 use function Activitypub\get_enclosures;
22 use function Activitypub\get_max_attachments;
23 use function Activitypub\get_rest_url_by_path;
24 use function Activitypub\is_post_publicly_queryable;
25 use function Activitypub\is_single_user;
26 use function Activitypub\site_supports_blocks;
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 * The content.
48 *
49 * @var string|false False indicates not yet computed.
50 */
51 private $content = false;
52
53 /**
54 * The summary.
55 *
56 * @var string|null|false False indicates not yet computed.
57 */
58 private $summary = false;
59
60 /**
61 * The tags.
62 *
63 * @var array|false False indicates not yet computed.
64 */
65 private $tags = false;
66
67 /**
68 * The attachment.
69 *
70 * @var array|false False indicates not yet computed.
71 */
72 private $attachment = false;
73
74 /**
75 * The mentions.
76 *
77 * @var array|false False indicates not yet computed.
78 */
79 private $mentions = false;
80
81 /**
82 * The in_reply_to.
83 *
84 * @var string|array|null|false False indicates not yet computed.
85 */
86 private $in_reply_to = false;
87
88 /**
89 * Transforms the WP_Post object to an ActivityPub Object
90 *
91 * @return \Activitypub\Activity\Base_Object The ActivityPub Object
92 */
93 public function to_object() {
94 /*
95 * A redacted (password-protected or non-public) post is, from the
96 * Fediverse's perspective, gone — the soft-delete path that reaches here
97 * emits a Delete. Represent it as a Tombstone: content-free by type, so
98 * no body-derived field (content, summary, tags, @-mentions, location,
99 * attachments…) can ever leak, even one added to the transformer later.
100 *
101 * Address the teardown to the public collection. A post only reaches the
102 * soft-delete path after being federated, and only public / quiet-public
103 * posts federate (private and local ones never do), so the original
104 * audience was always public — broadcasting the Delete tears the copy
105 * down everywhere it may exist. Private/direct activities are deleted via
106 * their own outbox path and keep their original (non-public) audience, so
107 * they are not affected by this.
108 */
109 if ( $this->is_redacted() ) {
110 $tombstone = $this->to_tombstone();
111 $tombstone->set_to( array( 'https://www.w3.org/ns/activitystreams#Public' ) );
112
113 return $tombstone;
114 }
115
116 $post = $this->item;
117 $object = parent::to_object();
118
119 $content_warning = get_content_warning( $post );
120 if ( ! empty( $content_warning ) ) {
121 $object->set_sensitive( true );
122 $object->set_summary( $content_warning );
123 $object->set_summary_map( null );
124 $object->set_dcterms( array( 'subject' => $content_warning ) );
125 }
126
127 return $object;
128 }
129
130 /**
131 * Returns a Tombstone object for the post.
132 *
133 * @return Base_Object The Tombstone object.
134 */
135 public function to_tombstone() {
136 $object = new Base_Object();
137 $object->set_type( 'Tombstone' );
138 $object->set_id( $this->get_id() );
139 // Preserve the permalink so the tombstone registry can resolve a request
140 // to it, even on sites whose ActivityPub ID is the post-ID URL (?p=123).
141 $object->set_url( $this->get_url() );
142 $object->set_former_type( $this->get_type() );
143 $object->set_published( $this->get_published() );
144 $object->set_updated( $this->get_updated() );
145
146 $deleted_at = \get_post_meta( $this->item->ID, 'activitypub_deleted_at', true );
147 if ( $deleted_at ) {
148 $object->set_deleted( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $deleted_at ) );
149 }
150
151 return $object;
152 }
153
154 /**
155 * Get the content visibility.
156 *
157 * @return string The content visibility.
158 */
159 public function get_content_visibility() {
160 if ( ! $this->content_visibility ) {
161 return get_content_visibility( $this->item );
162 }
163
164 return $this->content_visibility;
165 }
166
167 /**
168 * Get the Interaction Policy.
169 *
170 * @see https://docs.gotosocial.org/en/latest/federation/interaction_policy/
171 *
172 * @return array The interaction policy.
173 */
174 public function get_interaction_policy() {
175 return array(
176 'canAnnounce' => $this->get_public_interaction_policy(),
177 'canLike' => $this->get_public_interaction_policy(),
178 'canQuote' => $this->get_quote_policy(),
179 'canReply' => $this->get_public_interaction_policy(),
180 );
181 }
182
183 /**
184 * Returns the User-Object of the Author of the Post.
185 *
186 * If `single_user` mode is enabled, the Blog-User is returned.
187 *
188 * @return \Activitypub\Activity\Actor The User-Object.
189 */
190 public function get_actor_object() {
191 if ( $this->actor_object ) {
192 return $this->actor_object;
193 }
194
195 $blog_user = new Blog();
196 $this->actor_object = $blog_user;
197
198 if ( is_single_user() ) {
199 return $blog_user;
200 }
201
202 $user = Actors::get_by_id( $this->item->post_author );
203
204 if ( $user && ! \is_wp_error( $user ) ) {
205 $this->actor_object = $user;
206 return $user;
207 }
208
209 return $blog_user;
210 }
211
212 /**
213 * Returns the ID of the Post.
214 *
215 * Posts past `activitypub_last_post_with_permalink_as_id` use the post-ID URL
216 * as their canonical ActivityPub ID — stable across slug changes. Posts at
217 * or below the threshold are *legacy* and use their permalink as the ID,
218 * which means a slug change effectively renames the federated object.
219 *
220 * Known limitation: a legacy post whose slug changes in the same save as
221 * a soft-delete transition (e.g. publish → draft + new post_name) will
222 * emit a Delete targeting the new permalink, while remote servers cached
223 * the original. The trash case mitigates this via the `wp_trash_post`
224 * hook caching the pre-transition URL in `_activitypub_canonical_url`,
225 * but draft / pending / private / password-applied transitions do not.
226 * If you maintain a site that pre-dates the ID migration, avoid editing
227 * the slug in the same save as the visibility change.
228 *
229 * @return string The Posts ID.
230 */
231 public function get_id() {
232 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
233 $post_id = (int) $this->item->ID;
234
235 if ( $post_id > $last_legacy_id ) {
236 // Generate URI based on post ID.
237 return \add_query_arg( 'p', $post_id, \home_url( '/' ) );
238 }
239
240 return $this->get_url();
241 }
242
243 /**
244 * Returns the URL of the Post.
245 *
246 * @return string The Posts URL.
247 */
248 public function get_url() {
249 $post = $this->item;
250
251 switch ( \get_post_status( $post ) ) {
252 case 'trash':
253 $permalink = \get_post_meta( $post->ID, '_activitypub_canonical_url', true );
254 break;
255 case 'draft':
256 // Get_sample_permalink is in wp-admin, not always loaded.
257 if ( ! \function_exists( '\get_sample_permalink' ) ) {
258 require_once ABSPATH . 'wp-admin/includes/post.php';
259 }
260 $sample = \get_sample_permalink( $post->ID );
261 $permalink = \str_replace( array( '%pagename%', '%postname%' ), $sample[1], $sample[0] );
262 break;
263 default:
264 $permalink = \get_permalink( $post );
265 break;
266 }
267
268 return \esc_url_raw( $permalink );
269 }
270
271 /**
272 * Returns the User-URL of the Author of the Post.
273 *
274 * If `single_user` mode is enabled, the URL of the Blog-User is returned.
275 *
276 * @return string The User-URL.
277 */
278 protected function get_attributed_to() {
279 return $this->get_actor_object()->get_id();
280 }
281
282 /**
283 * Returns the featured image as `Image`.
284 *
285 * @return array|null The Image or null if no image is available.
286 */
287 protected function get_image() {
288 $post_id = $this->item->ID;
289
290 // List post thumbnail first if this post has one.
291 if (
292 ! \function_exists( 'has_post_thumbnail' ) ||
293 ! \has_post_thumbnail( $post_id )
294 ) {
295 return null;
296 }
297
298 $id = \get_post_thumbnail_id( $post_id );
299 $image_size = 'large';
300
301 /**
302 * Filter the image URL returned for each post.
303 *
304 * @param array|false $thumbnail The image URL, or false if no image is available.
305 * @param int $id The attachment ID.
306 * @param string $image_size The image size to retrieve. Set to 'large' by default.
307 */
308 $thumbnail = \apply_filters(
309 'activitypub_get_image',
310 $this->get_attachment_image_src( $id, $image_size ),
311 $id,
312 $image_size
313 );
314
315 if ( ! $thumbnail ) {
316 return null;
317 }
318
319 $mime_type = \get_post_mime_type( $id );
320
321 $image = array(
322 'type' => 'Image',
323 'url' => \esc_url_raw( $thumbnail[0] ),
324 'mediaType' => \esc_attr( $mime_type ),
325 );
326
327 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
328 if ( $alt ) {
329 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt, ENT_QUOTES, 'UTF-8' ) );
330 }
331
332 return $image;
333 }
334
335 /**
336 * Returns an Icon, based on the Featured Image with a fallback to the site-icon.
337 *
338 * @return array|null The Icon or null if no icon is available.
339 */
340 protected function get_icon() {
341 $post_id = $this->item->ID;
342
343 // List post thumbnail first if this post has one.
344 if ( \has_post_thumbnail( $post_id ) ) {
345 $id = \get_post_thumbnail_id( $post_id );
346 } else {
347 // Try site_logo, falling back to site_icon, first.
348 $id = \get_option( 'site_icon' );
349 }
350
351 if ( ! $id ) {
352 return null;
353 }
354
355 $image_size = 'thumbnail';
356
357 /**
358 * Filter the image URL returned for each post.
359 *
360 * @param array|false $thumbnail The image URL, or false if no image is available.
361 * @param int $id The attachment ID.
362 * @param string $image_size The image size to retrieve. Set to 'large' by default.
363 */
364 $thumbnail = \apply_filters(
365 'activitypub_get_image',
366 $this->get_attachment_image_src( $id, $image_size ),
367 $id,
368 $image_size
369 );
370
371 if ( ! $thumbnail ) {
372 return null;
373 }
374
375 $mime_type = \get_post_mime_type( $id );
376
377 $image = array(
378 'type' => 'Image',
379 'url' => \esc_url_raw( $thumbnail[0] ),
380 'mediaType' => \esc_attr( $mime_type ),
381 );
382
383 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
384 if ( $alt ) {
385 $image['name'] = \wp_strip_all_tags( \html_entity_decode( $alt, ENT_QUOTES, 'UTF-8' ) );
386 }
387
388 return $image;
389 }
390
391 /**
392 * Generates all Media Attachments for a Post.
393 *
394 * @return array The Attachments.
395 */
396 protected function get_attachment() {
397 if ( false !== $this->attachment ) {
398 return $this->attachment;
399 }
400
401 $max_media = get_max_attachments( $this->item->ID );
402
403 if ( 0 === $max_media ) {
404 $this->attachment = array();
405
406 return $this->attachment;
407 }
408
409 $media = array(
410 'image' => array(),
411 'audio' => array(),
412 'video' => array(),
413 );
414 $id = $this->item->ID;
415
416 // List post thumbnail first if this post has one.
417 if ( \has_post_thumbnail( $id ) ) {
418 $media['image'][] = array( 'id' => \get_post_thumbnail_id( $id ) );
419 }
420
421 $media = $this->get_enclosures( $media );
422
423 if ( site_supports_blocks() && \has_blocks( $this->item->post_content ) ) {
424 $media = $this->get_block_attachments( $media, $max_media );
425 } else {
426 $media = $this->parse_html_images( $media, $max_media, $this->item->post_content );
427 }
428
429 $media = $this->filter_media_by_object_type( $media, \get_post_format( $this->item ), $this->item );
430
431 /**
432 * Filter the attachment IDs for a post.
433 *
434 * @param array $media The media array grouped by type.
435 * @param \WP_Post $item The post object.
436 *
437 * @return array The filtered attachment IDs.
438 */
439 $media = \apply_filters( 'activitypub_attachment_ids', $media, $this->item );
440
441 // Deduplicate and limit after filter to ensure plugins adding attachments don't cause duplicates.
442 $media = $this->filter_unique_attachments( $media );
443 $media = \array_slice( $media, 0, $max_media );
444
445 $attachments = \array_filter( \array_map( array( $this, 'transform_attachment' ), $media ) );
446
447 /**
448 * Filter the attachments for a post.
449 *
450 * @param array $attachments The attachments.
451 * @param \WP_Post $item The post object.
452 *
453 * @return array The filtered attachments.
454 */
455 $this->attachment = \apply_filters( 'activitypub_attachments', $attachments, $this->item );
456
457 return $this->attachment;
458 }
459
460 /**
461 * Returns the ActivityStreams 2.0 Object-Type for a Post based on the
462 * settings and the Post-Type.
463 *
464 * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
465 *
466 * @return string The Object-Type.
467 */
468 protected function get_type() {
469 $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
470
471 if ( 'wordpress-post-format' !== $post_format_setting ) {
472 $object_type = \ucfirst( $post_format_setting );
473 } elseif ( ! \post_type_supports( $this->item->post_type, 'title' ) || ! $this->item->post_title ) {
474 $object_type = 'Note';
475 } elseif ( 'page' === \get_post_type( $this->item ) ) {
476 $object_type = 'Page';
477 } elseif ( ! \get_post_format( $this->item ) ) {
478 $object_type = 'Article';
479 } else {
480 $object_type = 'Note';
481 }
482
483 /**
484 * Filters the ActivityPub object type for a post.
485 *
486 * Allows downstream consumers to override the discriminator that
487 * decides whether a post federates as Note, Article, or Page.
488 * The filtered value propagates to all internal callers of
489 * get_type(), including former_type/tombstone handling,
490 * summary and title decisions, the content template, and the
491 * preview guard, not only the wire-format type property.
492 *
493 * @since 8.1.1
494 *
495 * @param string $object_type The computed ActivityPub object type.
496 * @param \WP_Post $post The WordPress post being transformed.
497 */
498 return \apply_filters( 'activitypub_post_object_type', $object_type, $this->item );
499 }
500
501 /**
502 * Returns the Audience for the Post.
503 *
504 * @return string|null The audience.
505 */
506 public function get_audience() {
507 $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE );
508
509 if ( ACTIVITYPUB_ACTOR_AND_BLOG_MODE === $actor_mode ) {
510 $blog = new Blog();
511 return $blog->get_id();
512 }
513
514 return null;
515 }
516
517 /**
518 * Returns a list of Tags, used in the Post.
519 *
520 * This includes Hash-Tags and Mentions.
521 *
522 * @return array The list of Tags.
523 */
524 protected function get_tag() {
525 if ( false !== $this->tags ) {
526 return $this->tags;
527 }
528
529 $tags = parent::get_tag();
530
531 $post_tags = \get_the_tags( $this->item->ID );
532 if ( $post_tags ) {
533 foreach ( $post_tags as $post_tag ) {
534 // Tag can be empty.
535 if ( ! $post_tag ) {
536 continue;
537 }
538
539 $tags[] = array(
540 'type' => 'Hashtag',
541 'href' => \esc_url_raw( \get_tag_link( $post_tag->term_id ) ),
542 'name' => esc_hashtag( $post_tag->name ),
543 );
544 }
545 }
546
547 $this->tags = \array_unique( $tags, SORT_REGULAR );
548
549 return $this->tags;
550 }
551
552 /**
553 * Returns the summary for the ActivityPub Item.
554 *
555 * The summary will be generated based on the user settings and only if the
556 * object type is not set to `note`.
557 *
558 * @return string|null The summary or null if the object type is `note`.
559 */
560 protected function get_summary() {
561 if ( 'Note' === $this->get_type() ) {
562 return null;
563 }
564
565 if ( false !== $this->summary ) {
566 return $this->summary;
567 }
568
569 $this->summary = generate_post_summary( $this->item );
570
571 return $this->summary;
572 }
573
574 /**
575 * Returns the title for the ActivityPub Item.
576 *
577 * The title will be generated based on the user settings and only if the
578 * object type is not set to `note`.
579 *
580 * @return string|null The title or null if the object type is `note`.
581 */
582 protected function get_name() {
583 if ( 'Note' === $this->get_type() ) {
584 return null;
585 }
586
587 $title = \get_the_title( $this->item->ID );
588
589 if ( ! $title ) {
590 return null;
591 }
592
593 return \wp_strip_all_tags(
594 \html_entity_decode(
595 $title
596 )
597 );
598 }
599
600 /**
601 * Returns the content for the ActivityPub Item.
602 *
603 * The content will be generated based on the user settings.
604 *
605 * @return string The content.
606 */
607 protected function get_content() {
608 if ( false !== $this->content ) {
609 return $this->content;
610 }
611
612 global $post;
613
614 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
615 $post = $this->item;
616 $content = $this->get_post_content_template();
617
618 /**
619 * Provides an action hook so plugins can add their own hooks/filters before AP content is generated.
620 *
621 * 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.
622 *
623 * @param \WP_Post $post The post object.
624 */
625 \do_action( 'activitypub_before_get_content', $post );
626
627 // It seems that shortcodes are only applied to published posts.
628 if ( \is_preview() ) {
629 $post->post_status = 'publish';
630 }
631
632 // Register our shortcodes just in time.
633 Shortcodes::register();
634 // Fill in the shortcodes.
635 \setup_postdata( $post );
636 $content = \do_shortcode( $content );
637 \wp_reset_postdata();
638
639 // Don't need these anymore, should never appear in a post.
640 Shortcodes::unregister();
641
642 /**
643 * Filters the post content after it was transformed for ActivityPub.
644 *
645 * @param string $content The transformed post content.
646 * @param \WP_Post $post The post object being transformed.
647 */
648 $this->content = \apply_filters( 'activitypub_the_content', $content, $post );
649
650 return $this->content;
651 }
652
653 /**
654 * Returns the in-reply-to URL of the post.
655 *
656 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
657 *
658 * @return string|array|null The in-reply-to URL of the post.
659 */
660 protected function get_in_reply_to() {
661 if ( false !== $this->in_reply_to ) {
662 return $this->in_reply_to;
663 }
664
665 if ( ! site_supports_blocks() ) {
666 $this->in_reply_to = null;
667 return $this->in_reply_to;
668 }
669
670 $reply_urls = array();
671 $blocks = \parse_blocks( $this->item->post_content );
672
673 foreach ( $blocks as $block ) {
674 if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) {
675
676 // Check if the URL has been validated as ActivityPub. Default to true for backwards compatibility.
677 if ( $block['attrs']['isValidActivityPub'] ?? true ) {
678 $reply_urls[] = $block['attrs']['url'];
679 }
680 }
681 }
682
683 if ( empty( $reply_urls ) ) {
684 $this->in_reply_to = null;
685
686 return $this->in_reply_to;
687 }
688
689 if ( 1 === \count( $reply_urls ) ) {
690 $this->in_reply_to = \current( $reply_urls );
691
692 return $this->in_reply_to;
693 }
694
695 $this->in_reply_to = \array_values( \array_unique( $reply_urls ) );
696
697 return $this->in_reply_to;
698 }
699
700 /**
701 * Returns the published date of the post.
702 *
703 * @return string The published date of the post.
704 */
705 protected function get_published() {
706 $published = \strtotime( $this->item->post_date_gmt );
707
708 return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $published );
709 }
710
711 /**
712 * Returns the updated date of the post.
713 *
714 * @return string|null The updated date of the post.
715 */
716 protected function get_updated() {
717 $published = \strtotime( $this->item->post_date_gmt );
718 $updated = \strtotime( $this->item->post_modified_gmt );
719
720 if ( $updated > $published ) {
721 return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $updated );
722 }
723
724 return null;
725 }
726
727 /**
728 * Returns the location of the post as a Place object.
729 *
730 * Uses WordPress Geodata post meta fields to build the location.
731 *
732 * @see https://codex.wordpress.org/Geodata
733 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-location
734 *
735 * @return array|null The Place object or null if no public geodata is available.
736 */
737 protected function get_location() {
738 $post_id = $this->item->ID;
739 $meta = \get_post_meta( $post_id );
740
741 // If geo_public exists and is explicitly set to 0, don't share location.
742 if ( isset( $meta['geo_public'] ) && '0' === $meta['geo_public'][0] ) {
743 return null;
744 }
745
746 // Both latitude and longitude are required for a valid location.
747 // Use is_numeric() instead of empty() since 0 is a valid coordinate (Equator/Prime Meridian).
748 $has_latitude = isset( $meta['geo_latitude'][0] ) && \is_numeric( $meta['geo_latitude'][0] );
749 $has_longitude = isset( $meta['geo_longitude'][0] ) && \is_numeric( $meta['geo_longitude'][0] );
750
751 if ( ! $has_latitude || ! $has_longitude ) {
752 return null;
753 }
754
755 $place = array(
756 'type' => 'Place',
757 'latitude' => (float) $meta['geo_latitude'][0],
758 'longitude' => (float) $meta['geo_longitude'][0],
759 );
760
761 // Add the address/name if available.
762 if ( ! empty( $meta['geo_address'][0] ) ) {
763 $place['name'] = \sanitize_text_field( $meta['geo_address'][0] );
764 }
765
766 /**
767 * Filter the location Place object for a post.
768 *
769 * @param array $place The Place object.
770 * @param \WP_Post $post The post object.
771 * @param int $post_id The post ID.
772 *
773 * @return array|null The filtered Place object or null to disable location.
774 */
775 return \apply_filters( 'activitypub_post_location', $place, $this->item, $post_id );
776 }
777
778 /**
779 * Helper function to extract the @-Mentions from the post content.
780 *
781 * @return array The list of @-Mentions.
782 */
783 protected function get_mentions() {
784 if ( false !== $this->mentions ) {
785 return $this->mentions;
786 }
787
788 /**
789 * Filter the mentions in the post content.
790 *
791 * @param array $mentions The mentions.
792 * @param string $content The post content.
793 * @param \WP_Post $post The post object.
794 *
795 * @return array The filtered mentions.
796 */
797 $this->mentions = \apply_filters(
798 'activitypub_extract_mentions',
799 array(),
800 $this->item->post_content . ' ' . $this->item->post_excerpt,
801 $this->item
802 );
803
804 return $this->mentions;
805 }
806
807 /**
808 * Whether the post should be redacted from ActivityPub representations.
809 *
810 * Redaction is fail-closed at a single boundary: `to_object()` returns a
811 * Tombstone instead of transforming the post, so no body-derived field
812 * (content, summary, name, preview, attachments, image/icon, tags, mentions,
813 * in-reply-to, location) is ever read — not even one added to the transformer
814 * later. This is the only caller of this gate.
815 *
816 * A post is redacted exactly when it is not publicly queryable — the same
817 * predicate the scheduler uses to decide a federated post should emit a
818 * Delete (`is_post_publicly_queryable()`), so the two never disagree. That
819 * covers non-public status, password protection, the `local`/`private`
820 * content-visibility meta, and a post type that no longer supports
821 * ActivityPub. The Fediverse Preview keeps working because
822 * `is_post_publicly_queryable()` itself treats a draft/pending/scheduled
823 * post as queryable during a `?preview=true` request from a user who can
824 * edit it.
825 *
826 * Note: we deliberately rely on `is_post_publicly_queryable()` rather than
827 * `post_password_required()`. Federation output is per-instance, never
828 * per-request, and `post_password_required()` returns false when a valid
829 * `wp-postpass` cookie is on the current request (e.g. an editor who unlocked
830 * the post), which would leak the protected body into an outbox snapshot.
831 *
832 * @return boolean True if the post must be redacted, false otherwise.
833 */
834 protected function is_redacted() {
835 return ! is_post_publicly_queryable( $this->item );
836 }
837
838 /**
839 * Get enclosures for a post.
840 *
841 * @param array $media The media array grouped by type.
842 *
843 * @return array The media array extended with enclosures.
844 */
845 protected function get_enclosures( $media ) {
846 $enclosures = get_enclosures( $this->item->ID );
847
848 if ( ! $enclosures ) {
849 return $media;
850 }
851
852 foreach ( $enclosures as $enclosure ) {
853 // Check if URL is an attachment.
854 $attachment_id = \attachment_url_to_postid( $enclosure['url'] );
855
856 if ( $attachment_id ) {
857 $enclosure['id'] = $attachment_id;
858 $enclosure['url'] = \wp_get_attachment_url( $attachment_id );
859 $enclosure['mediaType'] = \get_post_mime_type( $attachment_id );
860 }
861
862 $mime_type = $enclosure['mediaType'];
863 $media_type = \strtok( $mime_type, '/' );
864 $enclosure['type'] = \ucfirst( $media_type );
865
866 switch ( $media_type ) {
867 case 'image':
868 $media['image'][] = $enclosure;
869 break;
870 case 'audio':
871 $media['audio'][] = $enclosure;
872 break;
873 case 'video':
874 $media['video'][] = $enclosure;
875 break;
876 }
877 }
878
879 return $media;
880 }
881
882 /**
883 * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments.
884 *
885 * @param array $media The media array grouped by type.
886 * @param int $max_media The maximum number of attachments to return.
887 *
888 * @return array The attachments.
889 */
890 protected function get_block_attachments( $media, $max_media ) {
891 // Max media can't be negative or zero.
892 if ( $max_media <= 0 ) {
893 return array();
894 }
895
896 $blocks = \parse_blocks( $this->item->post_content );
897
898 return $this->get_media_from_blocks( $blocks, $media );
899 }
900
901 /**
902 * Recursively get media IDs from blocks.
903 *
904 * @param array $blocks The blocks to search for media IDs.
905 * @param array $media The media IDs to append new IDs to.
906 *
907 * @return array The image IDs.
908 */
909 protected function get_media_from_blocks( $blocks, $media ) {
910 foreach ( $blocks as $block ) {
911 // Recurse into inner blocks.
912 if ( ! empty( $block['innerBlocks'] ) ) {
913 $media = $this->get_media_from_blocks( $block['innerBlocks'], $media );
914 }
915
916 switch ( $block['blockName'] ) {
917 case 'core/image':
918 case 'core/cover':
919 if ( ! empty( $block['attrs']['id'] ) ) {
920 $alt = '';
921 $processor = new \WP_HTML_Tag_Processor( $block['innerHTML'] );
922 if ( $processor->next_tag( array( 'tag_name' => 'img' ) ) ) {
923 $alt = $processor->get_attribute( 'alt' ) ?? '';
924 }
925
926 $found = false;
927 foreach ( $media['image'] as $i => $image ) {
928 if ( isset( $image['id'] ) && $image['id'] === $block['attrs']['id'] ) {
929 $media['image'][ $i ]['alt'] = $alt;
930 $found = true;
931 break;
932 }
933 }
934
935 if ( ! $found ) {
936 $media['image'][] = array(
937 'id' => $block['attrs']['id'],
938 'alt' => $alt,
939 );
940 }
941 }
942 break;
943 case 'core/media-text':
944 if ( ! empty( $block['attrs']['mediaId'] ) ) {
945 $media_id = $block['attrs']['mediaId'];
946
947 // Media & Text holds either an image or a video; the default is image.
948 if ( 'video' === ( $block['attrs']['mediaType'] ?? 'image' ) ) {
949 $video = array( 'id' => $media_id );
950
951 // The poster is stored as an HTML attribute on the <video> tag, not in block attrs.
952 $processor = new \WP_HTML_Tag_Processor( $block['innerHTML'] );
953 if ( $processor->next_tag( array( 'tag_name' => 'video' ) ) ) {
954 $poster = $processor->get_attribute( 'poster' );
955 if ( ! empty( $poster ) ) {
956 $video['icon'] = \esc_url_raw( $poster );
957 }
958 }
959
960 $media['video'][] = $video;
961 } else {
962 $alt = '';
963 $processor = new \WP_HTML_Tag_Processor( $block['innerHTML'] );
964 if ( $processor->next_tag( array( 'tag_name' => 'img' ) ) ) {
965 $alt = $processor->get_attribute( 'alt' ) ?? '';
966 }
967
968 // Update alt in place if the image was already collected, so a
969 // duplicate ID does not get dropped (and its alt lost) later.
970 $found = false;
971 foreach ( $media['image'] as $i => $image ) {
972 if ( isset( $image['id'] ) && $image['id'] === $media_id ) {
973 $media['image'][ $i ]['alt'] = $alt;
974 $found = true;
975 break;
976 }
977 }
978
979 if ( ! $found ) {
980 $media['image'][] = array(
981 'id' => $media_id,
982 'alt' => $alt,
983 );
984 }
985 }
986 }
987 break;
988 case 'core/audio':
989 if ( ! empty( $block['attrs']['id'] ) ) {
990 $media['audio'][] = array( 'id' => $block['attrs']['id'] );
991 }
992 break;
993 case 'core/video':
994 case 'videopress/video':
995 if ( ! empty( $block['attrs']['id'] ) ) {
996 $video = array( 'id' => $block['attrs']['id'] );
997
998 // The poster is stored as an HTML attribute on the <video> tag, not in block attrs.
999 $processor = new \WP_HTML_Tag_Processor( $block['innerHTML'] );
1000 if ( $processor->next_tag( array( 'tag_name' => 'video' ) ) ) {
1001 $poster = $processor->get_attribute( 'poster' );
1002 if ( ! empty( $poster ) ) {
1003 $video['icon'] = \esc_url_raw( $poster );
1004 }
1005 }
1006
1007 $media['video'][] = $video;
1008 }
1009 break;
1010 case 'jetpack/slideshow':
1011 case 'jetpack/tiled-gallery':
1012 if ( ! empty( $block['attrs']['ids'] ) ) {
1013 $media['image'] = \array_merge(
1014 $media['image'],
1015 \array_map(
1016 static function ( $id ) {
1017 return array( 'id' => $id );
1018 },
1019 $block['attrs']['ids']
1020 )
1021 );
1022 }
1023 break;
1024 case 'jetpack/image-compare':
1025 if ( ! empty( $block['attrs']['beforeImageId'] ) ) {
1026 $media['image'][] = array( 'id' => $block['attrs']['beforeImageId'] );
1027 }
1028 if ( ! empty( $block['attrs']['afterImageId'] ) ) {
1029 $media['image'][] = array( 'id' => $block['attrs']['afterImageId'] );
1030 }
1031 break;
1032 }
1033 }
1034
1035 return $media;
1036 }
1037
1038 /**
1039 * Filter media IDs by object type.
1040 *
1041 * @param array $media The media array grouped by type.
1042 * @param string $type The object type.
1043 * @param \WP_Post $item The post object.
1044 *
1045 * @return array The filtered media IDs.
1046 */
1047 protected function filter_media_by_object_type( $media, $type, $item ) {
1048 /**
1049 * Filter the object type for media attachments.
1050 *
1051 * @param string $type The object type.
1052 * @param \WP_Post $item The post object.
1053 *
1054 * @return string The filtered object type.
1055 */
1056 $type = \apply_filters( 'filter_media_by_object_type', \strtolower( $type ), $item );
1057
1058 if ( ! empty( $media[ $type ] ) ) {
1059 return $media[ $type ];
1060 }
1061
1062 return \array_filter( \array_merge( ...\array_values( $media ) ) );
1063 }
1064
1065 /**
1066 * Get the context of the post.
1067 *
1068 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
1069 *
1070 * @return string The context of the post.
1071 */
1072 protected function get_context() {
1073 return get_rest_url_by_path( \sprintf( 'posts/%d/context', $this->item->ID ) );
1074 }
1075
1076 /**
1077 * Gets the template to use to generate the content of the activitypub item.
1078 *
1079 * @return string The Template.
1080 */
1081 protected function get_post_content_template() {
1082 $content = \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT );
1083 $template = $content ?: ACTIVITYPUB_CUSTOM_POST_CONTENT;
1084
1085 $post_format_setting = \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE );
1086 $type = $this->get_type();
1087
1088 if ( 'wordpress-post-format' === $post_format_setting ) {
1089 $template = '';
1090
1091 /*
1092 * If the post is a note, not a reply, and does not have mentions
1093 * force the inclusion of the post title.
1094 */
1095 if (
1096 'Note' === $type
1097 && empty( $this->get_in_reply_to() )
1098 && empty( $this->get_mentions() )
1099 ) {
1100 $template .= '[ap_title type="html"]';
1101 }
1102
1103 $template .= '[ap_content]';
1104 }
1105
1106 /**
1107 * Filters the template used to generate ActivityPub object content.
1108 *
1109 * This filter allows developers to modify the template that determines how post
1110 * content is formatted in ActivityPub objects. The template can include special
1111 * shortcodes like [ap_title] and [ap_content] that are processed during content
1112 * generation.
1113 *
1114 * @since 7.6.0 Added the $type parameter.
1115 *
1116 * @param string $template The template string containing shortcodes.
1117 * @param \WP_Post $item The WordPress post object being transformed.
1118 * @param string $type ActivityStreams 2.0 Object-Type for the post.
1119 */
1120 return \apply_filters( 'activitypub_object_content_template', $template, $this->item, $type );
1121 }
1122
1123 /**
1124 * Get the replies Collection.
1125 *
1126 * @return array|null The replies collection on success or null on failure.
1127 */
1128 public function get_replies() {
1129 return Replies::get_collection( $this->item );
1130 }
1131
1132 /**
1133 * Get the likes Collection.
1134 *
1135 * @return array The likes collection.
1136 */
1137 public function get_likes() {
1138 return array(
1139 'id' => get_rest_url_by_path( \sprintf( 'posts/%d/likes', $this->item->ID ) ),
1140 'type' => 'Collection',
1141 'totalItems' => Interactions::count_by_type( $this->item->ID, 'like' ),
1142 );
1143 }
1144
1145 /**
1146 * Get the shares Collection.
1147 *
1148 * @return array The Shares collection.
1149 */
1150 public function get_shares() {
1151 return array(
1152 'id' => get_rest_url_by_path( \sprintf( 'posts/%d/shares', $this->item->ID ) ),
1153 'type' => 'Collection',
1154 'totalItems' => Interactions::count_by_type( $this->item->ID, 'repost' ) + Interactions::count_by_type( $this->item->ID, 'quote' ),
1155 );
1156 }
1157
1158 /**
1159 * Get the preview of the post.
1160 *
1161 * @return array|null The preview of the post or null if the post is not an Article.
1162 */
1163 public function get_preview() {
1164 if ( 'Article' !== $this->get_type() ) {
1165 return null;
1166 }
1167
1168 return array(
1169 'type' => 'Note',
1170 'content' => $this->get_summary(),
1171 );
1172 }
1173
1174 /**
1175 * Get the quote policy.
1176 *
1177 * @return array The quote policy.
1178 */
1179 private function get_quote_policy() {
1180 $policy = \get_post_meta( $this->item->ID, 'activitypub_interaction_policy_quote', true );
1181
1182 // Fall back to global default if not set.
1183 if ( ! $policy ) {
1184 $policy = \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE );
1185 }
1186
1187 switch ( $policy ) {
1188 case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
1189 return array( 'automaticApproval' => get_rest_url_by_path( \sprintf( 'actors/%d/followers', $this->item->post_author ) ) );
1190
1191 case ACTIVITYPUB_INTERACTION_POLICY_ME:
1192 return array( 'automaticApproval' => $this->get_self_interaction_policy() );
1193
1194 default:
1195 return $this->get_public_interaction_policy();
1196 }
1197 }
1198
1199 /**
1200 * Get the public interaction policy.
1201 *
1202 * @return array The public interaction policy.
1203 */
1204 private function get_public_interaction_policy() {
1205 return array(
1206 'automaticApproval' => 'https://www.w3.org/ns/activitystreams#Public',
1207 'always' => 'https://www.w3.org/ns/activitystreams#Public',
1208 );
1209 }
1210
1211 /**
1212 * Get the actor ID(s) for the `me` audience for use in interaction policies.
1213 *
1214 * @return string|array The actor ID(s).
1215 */
1216 private function get_self_interaction_policy() {
1217 switch ( \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ) ) {
1218 case ACTIVITYPUB_BLOG_MODE:
1219 return ( new Blog() )->get_id();
1220
1221 case ACTIVITYPUB_ACTOR_AND_BLOG_MODE:
1222 return array(
1223 $this->get_actor_object()->get_id(),
1224 ( new Blog() )->get_id(),
1225 );
1226
1227 default:
1228 return $this->get_actor_object()->get_id();
1229 }
1230 }
1231 }
1232