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

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