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 / integration / class-jetpack.php

class-jetpack.php in ActivityPub 9.3.0, at integration/class-jetpack.php

405 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack integration file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Integration;
9
10 use Activitypub\Collection\Followers;
11 use Activitypub\Collection\Following;
12 use Activitypub\Http;
13 use Automattic\Jetpack\Connection\Manager;
14 use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
15 use Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags;
16 use Automattic\Jetpack\Podcast\Settings as Podcast_Settings;
17
18 use function Activitypub\get_enclosures;
19 use function Activitypub\get_max_attachments;
20 use function Activitypub\is_activity_object;
21 use function Activitypub\normalize_url;
22
23 /**
24 * Jetpack integration class.
25 */
26 class Jetpack {
27
28 /**
29 * Initialize the class, registering WordPress hooks.
30 */
31 public static function init() {
32 if ( ! \defined( 'IS_WPCOM' ) ) {
33 \add_filter( 'jetpack_sync_options_whitelist', array( self::class, 'add_sync_options' ) );
34 \add_filter( 'jetpack_sync_post_meta_whitelist', array( self::class, 'add_sync_meta' ) );
35 \add_filter( 'jetpack_sync_comment_meta_whitelist', array( self::class, 'add_sync_comment_meta' ) );
36 \add_filter( 'jetpack_sync_whitelisted_comment_types', array( self::class, 'add_comment_types' ) );
37 \add_filter( 'jetpack_json_api_comment_types', array( self::class, 'add_comment_types' ) );
38 \add_filter( 'jetpack_api_include_comment_types_count', array( self::class, 'add_comment_types' ) );
39 }
40
41 if (
42 ( \defined( 'IS_WPCOM' ) && IS_WPCOM ) ||
43 ( \class_exists( '\Automattic\Jetpack\Connection\Manager' ) && ( new Manager() )->is_user_connected() )
44 ) {
45 \add_filter( 'activitypub_following_row_actions', array( self::class, 'add_reader_link' ), 10, 2 );
46 \add_filter( 'pre_option_activitypub_following_ui', array( self::class, 'pre_option_activitypub_following_ui' ) );
47 }
48
49 \add_action( 'load-post-new.php', array( self::class, 'adapt_post_share' ) );
50
51 // Enriched onto the already-assembled attachments rather than through a transformer subclass
52 // like Podlove/SSP: a subclass is winner-take-all, so a site running one of those alongside a
53 // Jetpack podcast would get one behaviour instead of both.
54 \add_filter( 'activitypub_attachments', array( self::class, 'add_podcast_attachments' ), 10, 2 );
55 }
56
57 /**
58 * Add ActivityPub options to the Jetpack sync allow list.
59 *
60 * @since 8.1.0
61 *
62 * @param array $allow_list The Jetpack sync options allow list.
63 *
64 * @return array The allow list with ActivityPub options.
65 */
66 public static function add_sync_options( $allow_list ) {
67 $allow_list[] = 'activitypub_blog_identifier';
68 $allow_list[] = 'activitypub_blog_description';
69 $allow_list[] = 'activitypub_header_image';
70 $allow_list[] = 'activitypub_actor_mode';
71
72 return $allow_list;
73 }
74
75 /**
76 * Add ActivityPub meta keys to the Jetpack sync allow list.
77 *
78 * @param array $allow_list The Jetpack sync allow list.
79 *
80 * @return array The Jetpack sync allow list with ActivityPub meta keys.
81 */
82 public static function add_sync_meta( $allow_list ) {
83 $allow_list[] = Followers::FOLLOWER_META_KEY;
84 $allow_list[] = Following::FOLLOWING_META_KEY;
85
86 return $allow_list;
87 }
88
89 /**
90 * Add ActivityPub comment meta keys to the Jetpack sync allow list.
91 *
92 * @param array $allow_list The Jetpack sync allow list.
93 *
94 * @return array The Jetpack sync allow list with ActivityPub comment meta keys.
95 */
96 public static function add_sync_comment_meta( $allow_list ) {
97 $allow_list[] = 'avatar_url';
98
99 return $allow_list;
100 }
101
102 /**
103 * Add custom comment types to the list of comment types.
104 *
105 * @param array $comment_types Default comment types.
106 *
107 * @return array The comment types with ActivityPub types added.
108 */
109 public static function add_comment_types( $comment_types ) {
110 $comment_types[] = 'like';
111 $comment_types[] = 'quote';
112 $comment_types[] = 'repost';
113
114 return \array_unique( $comment_types );
115 }
116
117 /**
118 * Add a "Reader" link to the bulk actions dropdown on the following list screen.
119 *
120 * @param array $actions The bulk actions.
121 * @param array $item The current following item.
122 *
123 * @return array The bulk actions with the "Reader" link.
124 */
125 public static function add_reader_link( $actions, $item ) {
126 // Do not show the link for pending follow requests.
127 if ( 'pending' === $item['status'] ) {
128 return $actions;
129 }
130
131 $feed = \get_post_meta( $item['id'], '_activitypub_actor_feed', true );
132
133 // Generate Reader URL based on environment.
134 if ( \defined( 'IS_WPCOM' ) && IS_WPCOM ) {
135 if ( empty( $feed['feed_id'] ) ) {
136 return $actions; // No feed_id available on WPCOM.
137 }
138 $url = \sprintf( 'https://wordpress.com/reader/feeds/%d', (int) $feed['feed_id'] );
139 } else {
140 $url = \sprintf( 'https://wordpress.com/reader/feeds/lookup/%s', \rawurlencode( $item['identifier'] ) );
141 }
142
143 return \array_merge(
144 array(
145 'reader' => \sprintf(
146 '<a href="%1$s" target="_blank">%2$s<span class="screen-reader-text"> %3$s</span><span aria-hidden="true" class="dashicons dashicons-external"></span></a>',
147 \esc_url( $url ),
148 \esc_html__( 'View Feed', 'activitypub' ),
149 /* translators: Hidden accessibility text. */
150 \esc_html__( '(opens in a new tab)', 'activitypub' )
151 ),
152 ),
153 $actions
154 );
155 }
156
157 /**
158 * Force the ActivityPub Following UI to be enabled when Jetpack is active.
159 *
160 * @return string '1' to enable the ActivityPub Following UI.
161 */
162 public static function pre_option_activitypub_following_ui() {
163 return '1';
164 }
165
166 /**
167 * Adapt the parameters for a post share request to be compatible with the Federated Reply block.
168 */
169 public static function adapt_post_share() {
170 if ( ! isset( $_GET['is_post_share'], $_GET['url'] ) || ! $_GET['is_post_share'] ) { // phpcs:ignore WordPress.Security
171 return;
172 }
173
174 $url = \sanitize_url( \wp_unslash( $_GET['url'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
175
176 if ( is_activity_object( Http::get_remote_object( $url ) ) ) {
177 $args = array(
178 'post_type' => 'post',
179 'in_reply_to' => $url,
180 );
181
182 \wp_safe_redirect( \add_query_arg( $args, \admin_url( 'post-new.php' ) ) );
183 exit;
184 }
185 }
186
187
188 /**
189 * Federate a podcast episode's audio as an ActivityPub attachment.
190 *
191 * Jetpack has two podcast surfaces, developed separately, and an episode can come from either:
192 *
193 * - Posts to Podcast writes a `jetpack/podcast-episode` block carrying the audio it produced.
194 * - Jetpack Podcast treats every post in the configured podcast category as an episode, whose
195 * audio is an ordinary WordPress enclosure.
196 *
197 * Neither survives the transformer on its own. Episode audio is usually hosted off-site, so it
198 * has no attachment ID, and {@see \Activitypub\Transformer\Base::filter_unique_attachments()}
199 * drops every media entry without one, meaning that audio never reaches this filter. It is
200 * resolved here instead, and either added or, when the media library already contributed it,
201 * given the artwork the podcast feed shows for the same episode.
202 *
203 * @param array $attachments The ActivityPub attachments.
204 * @param \WP_Post $post The post being transformed.
205 *
206 * @return array The attachments, with the episode audio added or enriched.
207 */
208 public static function add_podcast_attachments( $attachments, $post ) {
209 $is_show_episode = self::is_show_episode( $post );
210 $episode = self::get_episode_audio( $post, $is_show_episode );
211
212 if ( ! $episode ) {
213 return $attachments;
214 }
215
216 $icon = self::get_cover_art( $post, $episode['coverArt'] ?? '', $is_show_episode );
217 $index = self::find_attachment_by_url( $attachments, $episode['url'] );
218
219 if ( null !== $index ) {
220 /*
221 * The audio is already attached, so only the artwork is missing. It is replaced rather
222 * than filled in: the transformer falls back to the site icon for any audio without a
223 * poster, and the show's own cover is the better answer for an episode.
224 */
225 if ( $icon ) {
226 $attachments[ $index ]['icon'] = $icon;
227 }
228
229 return $attachments;
230 }
231
232 $audio = array(
233 'type' => $episode['type'],
234 'url' => $episode['url'],
235 'name' => \html_entity_decode( \wp_strip_all_tags( \get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' ),
236 );
237
238 // An episode attached by URL carries no mime type, and omitting the property beats sending
239 // an empty one, which stops a receiver classifying the attachment at all.
240 if ( $episode['mediaType'] ) {
241 $audio['mediaType'] = $episode['mediaType'];
242 }
243
244 if ( $icon ) {
245 $audio['icon'] = $icon;
246 }
247
248 \array_unshift( $attachments, $audio );
249
250 // The transformer trimmed to the configured maximum before this filter ran, so prepending
251 // the audio would otherwise put the post one attachment over a limit the site chose. An
252 // episode that arrived alone cannot exceed it, and the maximum is never 0 here because the
253 // transformer returns before this filter in that case.
254 if ( \count( $attachments ) > 1 ) {
255 $attachments = \array_slice( $attachments, 0, get_max_attachments( $post->ID ) );
256 }
257
258 return $attachments;
259 }
260
261 /**
262 * Resolve the audio of a podcast episode.
263 *
264 * A Posts to Podcast episode keeps its generated audio in the `jetpack/podcast-episode` block.
265 * A Jetpack Podcast episode instead carries an ordinary enclosure, which only counts as an
266 * episode when the post is filed in the configured podcast category, since any post may have an
267 * enclosure without being part of the show.
268 *
269 * @param \WP_Post $post The post being transformed.
270 * @param bool $is_show_episode Whether the post is filed in the configured podcast category.
271 *
272 * @return array|null The episode `type`, `url`, `mediaType` and `coverArt`, or null when the post is not an episode.
273 */
274 private static function get_episode_audio( $post, $is_show_episode ) {
275 $attrs = self::get_episode_block_attrs( $post );
276
277 if ( ! empty( $attrs['mediaUrl'] ) ) {
278 // Sanitizing drops an unsafe scheme, and an attachment without a URL is invalid.
279 $url = \esc_url_raw( $attrs['mediaUrl'] );
280
281 if ( $url ) {
282 return array(
283 'type' => \ucfirst( $attrs['mediaType'] ?? 'audio' ),
284 'url' => $url,
285 'mediaType' => \esc_attr( $attrs['mediaMimeType'] ?? '' ),
286 'coverArt' => empty( $attrs['coverArt']['url'] ) ? '' : \esc_url_raw( $attrs['coverArt']['url'] ),
287 );
288 }
289 }
290
291 if ( ! $is_show_episode ) {
292 return null;
293 }
294
295 foreach ( get_enclosures( $post->ID ) as $enclosure ) {
296 $mime_type = $enclosure['mediaType'] ?? '';
297
298 if ( ! \str_starts_with( $mime_type, 'audio/' ) ) {
299 continue;
300 }
301
302 $url = \esc_url_raw( $enclosure['url'] );
303
304 if ( $url ) {
305 return array(
306 'type' => 'Audio',
307 'url' => $url,
308 'mediaType' => \esc_attr( $mime_type ),
309 );
310 }
311 }
312
313 return null;
314 }
315
316 /**
317 * Resolve the cover art for a podcast episode.
318 *
319 * The episode's own artwork wins, then the post's featured image, then the show image. That is
320 * the order the podcast feed covers an item with, so a federated episode carries the artwork
321 * subscribers already see. The show image applies only to an episode of the show itself, so a
322 * generated episode on an unrelated post does not advertise the podcast's cover.
323 *
324 * @param \WP_Post $post The post being transformed.
325 * @param string $cover_art The episode's own artwork, when it has any.
326 * @param bool $is_show_episode Whether the post is filed in the configured podcast category.
327 *
328 * @return string The cover art URL, or an empty string when none is set.
329 */
330 private static function get_cover_art( $post, $cover_art, $is_show_episode ) {
331 if ( $cover_art ) {
332 return $cover_art;
333 }
334
335 $thumbnail = \get_the_post_thumbnail_url( $post, 'full' );
336
337 if ( $thumbnail ) {
338 return \esc_url_raw( $thumbnail );
339 }
340
341 if ( ! $is_show_episode || ! \method_exists( Podcast_Settings::class, 'raw_show_image_url' ) ) {
342 return '';
343 }
344
345 return \esc_url_raw( (string) Podcast_Settings::raw_show_image_url() );
346 }
347
348 /**
349 * Test whether a post is an episode of the site's own podcast.
350 *
351 * @param \WP_Post $post The post being transformed.
352 *
353 * @return bool Whether the post is in the configured podcast category.
354 */
355 private static function is_show_episode( $post ) {
356 if ( ! \method_exists( Customize_Feed::class, 'resolve_category_id' ) ) {
357 return false;
358 }
359
360 // The category can be stored as an ID or as an archive slug, and only Jetpack knows which applies.
361 $category_id = (int) Customize_Feed::resolve_category_id();
362
363 return $category_id && \in_category( $category_id, $post );
364 }
365
366 /**
367 * Read the attributes of the post's `jetpack/podcast-episode` block.
368 *
369 * @param \WP_Post $post The post being transformed.
370 *
371 * @return array The block attributes, empty when the post has no episode block.
372 */
373 private static function get_episode_block_attrs( $post ) {
374 if ( ! \method_exists( Episode_Block_Tags::class, 'get_block_attrs' ) ) {
375 return array();
376 }
377
378 return (array) Episode_Block_Tags::get_block_attrs( $post );
379 }
380
381 /**
382 * Find the attachment carrying a given media URL.
383 *
384 * Matched on host and path so the same file is still recognised when the stored enclosure and
385 * the episode block disagree on the scheme, which is the case on every site that moved to HTTPS
386 * after publishing.
387 *
388 * @param array $attachments The ActivityPub attachments.
389 * @param string $url The media URL to look for.
390 *
391 * @return int|string|null The attachment key, or null when the media is not in the list.
392 */
393 private static function find_attachment_by_url( $attachments, $url ) {
394 $needle = normalize_url( $url );
395
396 foreach ( $attachments as $index => $attachment ) {
397 if ( isset( $attachment['url'] ) && normalize_url( $attachment['url'] ) === $needle ) {
398 return $index;
399 }
400 }
401
402 return null;
403 }
404 }
405