PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/transformer/class-post.php +130 -100 8.2.19.2.1 View file →
@@ -7,9 +7,8 @@
7 7
8 8 namespace Activitypub\Transformer;
9 9
10 10 use Activitypub\Activity\Base_Object;
11 -use Activitypub\Blocks;
12 11 use Activitypub\Collection\Actors;
13 12 use Activitypub\Collection\Interactions;
14 13 use Activitypub\Collection\Replies;
15 14 use Activitypub\Model\Blog;
@@ -20,8 +19,9 @@
20 19 use function Activitypub\get_content_visibility;
21 20 use function Activitypub\get_content_warning;
22 21 use function Activitypub\get_enclosures;
23 22 use function Activitypub\get_rest_url_by_path;
23 +use function Activitypub\is_post_publicly_queryable;
24 24 use function Activitypub\is_single_user;
25 25 use function Activitypub\site_supports_blocks;
26 26
27 27 /**
@@ -89,8 +89,30 @@
89 89 *
90 90 * @return \Activitypub\Activity\Base_Object The ActivityPub Object
91 91 */
92 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 +
93 115 $post = $this->item;
94 116 $object = parent::to_object();
95 117
96 118 $content_warning = get_content_warning( $post );
@@ -112,8 +134,11 @@
112 134 public function to_tombstone() {
113 135 $object = new Base_Object();
114 136 $object->set_type( 'Tombstone' );
115 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() );
116 141 $object->set_former_type( $this->get_type() );
117 142 $object->set_published( $this->get_published() );
118 143 $object->set_updated( $this->get_updated() );
119 144
@@ -174,9 +199,9 @@
174 199 }
175 200
176 201 $user = Actors::get_by_id( $this->item->post_author );
177 202
178 - if ( $user && ! is_wp_error( $user ) ) {
203 + if ( $user && ! \is_wp_error( $user ) ) {
179 204 $this->actor_object = $user;
180 205 return $user;
181 206 }
182 207
@@ -185,8 +210,22 @@
185 210
186 211 /**
187 212 * Returns the ID of the Post.
188 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 + *
189 228 * @return string The Posts ID.
190 229 */
191 230 public function get_id() {
192 231 $last_legacy_id = (int) \get_option( 'activitypub_last_post_with_permalink_as_id', 0 );
@@ -224,9 +263,9 @@
224 263 $permalink = \get_permalink( $post );
225 264 break;
226 265 }
227 266
228 - return \esc_url( $permalink );
267 + return \esc_url_raw( $permalink );
229 268 }
230 269
231 270 /**
232 271 * Returns the User-URL of the Author of the Post.
@@ -264,9 +303,9 @@
264 303 * @param array|false $thumbnail The image URL, or false if no image is available.
265 304 * @param int $id The attachment ID.
266 305 * @param string $image_size The image size to retrieve. Set to 'large' by default.
267 306 */
268 - $thumbnail = apply_filters(
307 + $thumbnail = \apply_filters(
269 308 'activitypub_get_image',
270 309 $this->get_attachment_image_src( $id, $image_size ),
271 310 $id,
272 311 $image_size
@@ -279,9 +318,9 @@
279 318 $mime_type = \get_post_mime_type( $id );
280 319
281 320 $image = array(
282 321 'type' => 'Image',
283 - 'url' => \esc_url( $thumbnail[0] ),
322 + 'url' => \esc_url_raw( $thumbnail[0] ),
284 323 'mediaType' => \esc_attr( $mime_type ),
285 324 );
286 325
287 326 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
@@ -304,9 +343,9 @@
304 343 if ( \has_post_thumbnail( $post_id ) ) {
305 344 $id = \get_post_thumbnail_id( $post_id );
306 345 } else {
307 346 // Try site_logo, falling back to site_icon, first.
308 - $id = get_option( 'site_icon' );
347 + $id = \get_option( 'site_icon' );
309 348 }
310 349
311 350 if ( ! $id ) {
312 351 return null;
@@ -320,9 +359,9 @@
320 359 * @param array|false $thumbnail The image URL, or false if no image is available.
321 360 * @param int $id The attachment ID.
322 361 * @param string $image_size The image size to retrieve. Set to 'large' by default.
323 362 */
324 - $thumbnail = apply_filters(
363 + $thumbnail = \apply_filters(
325 364 'activitypub_get_image',
326 365 $this->get_attachment_image_src( $id, $image_size ),
327 366 $id,
328 367 $image_size
@@ -335,9 +374,9 @@
335 374 $mime_type = \get_post_mime_type( $id );
336 375
337 376 $image = array(
338 377 'type' => 'Image',
339 - 'url' => \esc_url( $thumbnail[0] ),
378 + 'url' => \esc_url_raw( $thumbnail[0] ),
340 379 'mediaType' => \esc_attr( $mime_type ),
341 380 );
342 381
343 382 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
@@ -357,21 +396,11 @@
357 396 if ( false !== $this->attachment ) {
358 397 return $this->attachment;
359 398 }
360 399
361 - /*
362 - * Remove attachments from the Fediverse if a post was federated and then unpublished.
363 - * Except in preview mode, where we want to show attachments.
364 - */
365 - if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) {
366 - $this->attachment = array();
367 -
368 - return $this->attachment;
369 - }
370 -
371 400 $max_media = \get_post_meta( $this->item->ID, 'activitypub_max_image_attachments', true );
372 401
373 - if ( ! is_numeric( $max_media ) ) {
402 + if ( ! \is_numeric( $max_media ) ) {
374 403 $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS );
375 404 }
376 405
377 406 /**
@@ -522,9 +551,9 @@
522 551 }
523 552
524 553 $tags[] = array(
525 554 'type' => 'Hashtag',
526 - 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
555 + 'href' => \esc_url_raw( \get_tag_link( $post_tag->term_id ) ),
527 556 'name' => esc_hashtag( $post_tag->name ),
528 557 );
529 558 }
530 559 }
@@ -550,15 +579,8 @@
550 579 if ( false !== $this->summary ) {
551 580 return $this->summary;
552 581 }
553 582
554 - // Remove Teaser from unpublished posts.
555 - if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) {
556 - $this->summary = \__( '(This post is being modified)', 'activitypub' );
557 -
558 - return $this->summary;
559 - }
560 -
561 583 $this->summary = generate_post_summary( $this->item );
562 584
563 585 return $this->summary;
564 586 }
@@ -600,15 +622,8 @@
600 622 if ( false !== $this->content ) {
601 623 return $this->content;
602 624 }
603 625
604 - // Remove Content from unpublished posts.
605 - if ( ! $this->is_preview() && 'publish' !== \get_post_status( $this->item ) ) {
606 - $this->content = \__( '(This post is being modified)', 'activitypub' );
607 -
608 - return $this->content;
609 - }
610 -
611 626 global $post;
612 627
613 628 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
614 629 $post = $this->item;
@@ -623,9 +638,9 @@
623 638 */
624 639 \do_action( 'activitypub_before_get_content', $post );
625 640
626 641 // It seems that shortcodes are only applied to published posts.
627 - if ( is_preview() ) {
642 + if ( \is_preview() ) {
628 643 $post->post_status = 'publish';
629 644 }
630 645
631 646 // Register our shortcodes just in time.
@@ -649,24 +664,8 @@
649 664 return $this->content;
650 665 }
651 666
652 667 /**
653 - * Generate HTML @ link for reply block.
654 - *
655 - * @deprecated 7.4.0 Use {@see Blocks::generate_reply_link()}.
656 - *
657 - * @param string $block_content The block content.
658 - * @param array $block The block data.
659 - *
660 - * @return string The HTML @ link.
661 - */
662 - public function generate_reply_link( $block_content, $block ) {
663 - _deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Blocks::generate_reply_link' );
664 -
665 - return Blocks::generate_reply_link( $block_content, $block );
666 - }
667 -
668 - /**
669 668 * Returns the in-reply-to URL of the post.
670 669 *
671 670 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
672 671 *
@@ -700,9 +699,9 @@
700 699
701 700 return $this->in_reply_to;
702 701 }
703 702
704 - if ( 1 === count( $reply_urls ) ) {
703 + if ( 1 === \count( $reply_urls ) ) {
705 704 $this->in_reply_to = \current( $reply_urls );
706 705
707 706 return $this->in_reply_to;
708 707 }
@@ -759,10 +758,10 @@
759 758 }
760 759
761 760 // Both latitude and longitude are required for a valid location.
762 761 // Use is_numeric() instead of empty() since 0 is a valid coordinate (Equator/Prime Meridian).
763 - $has_latitude = isset( $meta['geo_latitude'][0] ) && is_numeric( $meta['geo_latitude'][0] );
764 - $has_longitude = isset( $meta['geo_longitude'][0] ) && is_numeric( $meta['geo_longitude'][0] );
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] );
765 764
766 765 if ( ! $has_latitude || ! $has_longitude ) {
767 766 return null;
768 767 }
@@ -808,9 +807,9 @@
808 807 * @param \WP_Post $post The post object.
809 808 *
810 809 * @return array The filtered mentions.
811 810 */
812 - $this->mentions = apply_filters(
811 + $this->mentions = \apply_filters(
813 812 'activitypub_extract_mentions',
814 813 array(),
815 814 $this->item->post_content . ' ' . $this->item->post_excerpt,
816 815 $this->item
@@ -819,38 +818,39 @@
819 818 return $this->mentions;
820 819 }
821 820
822 821 /**
823 - * Transform Embed blocks to block level link.
822 + * Whether the post should be redacted from ActivityPub representations.
824 823 *
825 - * Remote servers will simply drop iframe elements, rendering incomplete content.
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.
826 829 *
827 - * @deprecated 7.4.0 Use {@see Blocks::revert_embed_links()}.
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.
828 839 *
829 - * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
830 - * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
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.
831 845 *
832 - * @param string $block_content The block content (html).
833 - * @param object $block The block object.
834 - *
835 - * @return string A block level link
846 + * @return boolean True if the post must be redacted, false otherwise.
836 847 */
837 - public function revert_embed_links( $block_content, $block ) {
838 - _deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Blocks::revert_embed_links' );
839 -
840 - return Blocks::revert_embed_links( $block_content, $block );
848 + protected function is_redacted() {
849 + return ! is_post_publicly_queryable( $this->item );
841 850 }
842 851
843 852 /**
844 - * Check if the post is a preview.
845 - *
846 - * @return boolean True if the post is a preview, false otherwise.
847 - */
848 - private function is_preview() {
849 - return defined( 'ACTIVITYPUB_PREVIEW' ) && ACTIVITYPUB_PREVIEW;
850 - }
851 -
852 - /**
853 853 * Get enclosures for a post.
854 854 *
855 855 * @param array $media The media array grouped by type.
856 856 *
@@ -953,8 +953,53 @@
953 953 );
954 954 }
955 955 }
956 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;
957 1002 case 'core/audio':
958 1003 if ( ! empty( $block['attrs']['id'] ) ) {
959 1004 $media['audio'][] = array( 'id' => $block['attrs']['id'] );
960 1005 }
@@ -978,11 +1023,11 @@
978 1023 break;
979 1024 case 'jetpack/slideshow':
980 1025 case 'jetpack/tiled-gallery':
981 1026 if ( ! empty( $block['attrs']['ids'] ) ) {
982 - $media['image'] = array_merge(
1027 + $media['image'] = \array_merge(
983 1028 $media['image'],
984 - array_map(
1029 + \array_map(
985 1030 static function ( $id ) {
986 1031 return array( 'id' => $id );
987 1032 },
988 1033 $block['attrs']['ids']
@@ -1027,27 +1072,12 @@
1027 1072 if ( ! empty( $media[ $type ] ) ) {
1028 1073 return $media[ $type ];
1029 1074 }
1030 1075
1031 - return array_filter( array_merge( ...array_values( $media ) ) );
1076 + return \array_filter( \array_merge( ...\array_values( $media ) ) );
1032 1077 }
1033 1078
1034 1079 /**
1035 - * Converts a WordPress Attachment to an ActivityPub Attachment.
1036 - *
1037 - * @deprecated 7.2.0 Use {@see Base::transform_attachment()} instead.
1038 - *
1039 - * @param array $media The Attachment array.
1040 - *
1041 - * @return array The ActivityPub Attachment.
1042 - */
1043 - public function wp_attachment_to_activity_attachment( $media ) {
1044 - _deprecated_function( __METHOD__, '7.2.0', '\Activitypub\Transformer\Base::transform_attachment()' );
1045 -
1046 - return parent::transform_attachment( $media );
1047 - }
1048 -
1049 - /**
1050 1080 * Get the context of the post.
1051 1081 *
1052 1082 * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
1053 1083 *
@@ -1053,9 +1083,9 @@
1053 1083 *
1054 1084 * @return string The context of the post.
1055 1085 */
1056 1086 protected function get_context() {
1057 - return get_rest_url_by_path( sprintf( 'posts/%d/context', $this->item->ID ) );
1087 + return get_rest_url_by_path( \sprintf( 'posts/%d/context', $this->item->ID ) );
1058 1088 }
1059 1089
1060 1090 /**
1061 1091 * Gets the template to use to generate the content of the activitypub item.
@@ -1100,9 +1130,9 @@
1100 1130 * @param string $template The template string containing shortcodes.
1101 1131 * @param \WP_Post $item The WordPress post object being transformed.
1102 1132 * @param string $type ActivityStreams 2.0 Object-Type for the post.
1103 1133 */
1104 - return apply_filters( 'activitypub_object_content_template', $template, $this->item, $type );
1134 + return \apply_filters( 'activitypub_object_content_template', $template, $this->item, $type );
1105 1135 }
1106 1136
1107 1137 /**
1108 1138 * Get the replies Collection.
@@ -1119,9 +1149,9 @@
1119 1149 * @return array The likes collection.
1120 1150 */
1121 1151 public function get_likes() {
1122 1152 return array(
1123 - 'id' => get_rest_url_by_path( sprintf( 'posts/%d/likes', $this->item->ID ) ),
1153 + 'id' => get_rest_url_by_path( \sprintf( 'posts/%d/likes', $this->item->ID ) ),
1124 1154 'type' => 'Collection',
1125 1155 'totalItems' => Interactions::count_by_type( $this->item->ID, 'like' ),
1126 1156 );
1127 1157 }
@@ -1132,9 +1162,9 @@
1132 1162 * @return array The Shares collection.
1133 1163 */
1134 1164 public function get_shares() {
1135 1165 return array(
1136 - 'id' => get_rest_url_by_path( sprintf( 'posts/%d/shares', $this->item->ID ) ),
1166 + 'id' => get_rest_url_by_path( \sprintf( 'posts/%d/shares', $this->item->ID ) ),
1137 1167 'type' => 'Collection',
1138 1168 'totalItems' => Interactions::count_by_type( $this->item->ID, 'repost' ) + Interactions::count_by_type( $this->item->ID, 'quote' ),
1139 1169 );
1140 1170 }
@@ -1169,9 +1199,9 @@
1169 1199 }
1170 1200
1171 1201 switch ( $policy ) {
1172 1202 case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS:
1173 - return array( 'automaticApproval' => get_rest_url_by_path( sprintf( 'actors/%d/followers', $this->item->post_author ) ) );
1203 + return array( 'automaticApproval' => get_rest_url_by_path( \sprintf( 'actors/%d/followers', $this->item->post_author ) ) );
1174 1204
1175 1205 case ACTIVITYPUB_INTERACTION_POLICY_ME:
1176 1206 return array( 'automaticApproval' => $this->get_self_interaction_policy() );
1177 1207