PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-blocks.php

class-blocks.php in ActivityPub 7.7.0, at includes/class-blocks.php

448 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Blocks file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11
12 /**
13 * Block class.
14 */
15 class Blocks {
16 /**
17 * Initialize the class, registering WordPress hooks.
18 */
19 public static function init() {
20 // This is already being called on the init hook, so just add it.
21 self::register_blocks();
22
23 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
24 // Add editor plugin.
25 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
26 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
27
28 \add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
29
30 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
31 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
32 }
33
34 /**
35 * Enqueue the block editor assets.
36 */
37 public static function enqueue_editor_assets() {
38 $data = array(
39 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
40 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
41 'enabled' => array(
42 'blog' => ! is_user_type_disabled( 'blog' ),
43 'users' => ! is_user_type_disabled( 'user' ),
44 ),
45 'profileUrls' => array(
46 'user' => \admin_url( 'profile.php#activitypub' ),
47 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
48 ),
49 );
50 wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
51
52 // Check for our supported post types.
53 $current_screen = \get_current_screen();
54 $ap_post_types = \get_post_types_by_support( 'activitypub' );
55 if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
56 return;
57 }
58
59 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
60 $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
61 wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
62 }
63
64 /**
65 * Enqueue the reply handle script if the in_reply_to GET param is set.
66 */
67 public static function handle_in_reply_to_get_param() {
68 // Only load the script if the in_reply_to GET param is set, action happens there, not here.
69 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
70 if ( ! isset( $_GET['in_reply_to'] ) ) {
71 return;
72 }
73
74 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
75 $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
76 wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
77 }
78
79 /**
80 * Register the blocks.
81 */
82 public static function register_blocks() {
83 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
84 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
85 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' );
86 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/reactions' );
87
88 \register_block_type_from_metadata(
89 ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
90 array(
91 'render_callback' => array( self::class, 'render_reply_block' ),
92 )
93 );
94 }
95
96 /**
97 * Register REST fields needed for blocks.
98 */
99 public static function register_rest_fields() {
100 // Register the post_count field for Follow Me block.
101 register_rest_field(
102 'user',
103 'post_count',
104 array(
105 /**
106 * Get the number of published posts.
107 *
108 * @param array $response Prepared response array.
109 * @param string $field_name The field name.
110 * @param \WP_REST_Request $request The request object.
111 * @return int The number of published posts.
112 */
113 'get_callback' => function ( $response, $field_name, $request ) {
114 return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
115 },
116 'schema' => array(
117 'description' => 'Number of published posts',
118 'type' => 'integer',
119 'context' => array( 'activitypub' ),
120 ),
121 )
122 );
123 }
124
125 /**
126 * Get the user ID from a user string.
127 *
128 * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
129 * @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
130 */
131 public static function get_user_id( $user_string ) {
132 if ( is_numeric( $user_string ) ) {
133 return absint( $user_string );
134 }
135
136 // If the user string is 'blog', return the Blog User ID.
137 if ( 'blog' === $user_string ) {
138 return Actors::BLOG_USER_ID;
139 }
140
141 // The only other value should be 'inherit', which means to use the query context to determine the User.
142 if ( 'inherit' !== $user_string ) {
143 return null;
144 }
145
146 // For a homepage/front page, if the Blog User is active, use it.
147 if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
148 return Actors::BLOG_USER_ID;
149 }
150
151 // If we're in a loop, use the post author.
152 $author_id = get_the_author_meta( 'ID' );
153 if ( $author_id ) {
154 return $author_id;
155 }
156
157 // For other pages, the queried object will clue us in.
158 $queried_object = get_queried_object();
159 if ( ! $queried_object ) {
160 return null;
161 }
162
163 // If we're on a user archive page, use that user's ID.
164 if ( is_a( $queried_object, 'WP_User' ) ) {
165 return $queried_object->ID;
166 }
167
168 // For a single post, use the post author's ID.
169 if ( is_a( $queried_object, 'WP_Post' ) ) {
170 return get_the_author_meta( 'ID' );
171 }
172
173 // We won't properly account for some conditions, like tag archives.
174 return null;
175 }
176
177 /**
178 * Render the reply block.
179 *
180 * @param array $attrs The block attributes.
181 *
182 * @return string The HTML to render.
183 */
184 public static function render_reply_block( $attrs ) {
185 if ( is_activitypub_request() ) {
186 $attrs['embedPost'] = false;
187 }
188
189 // Return early if no URL is provided.
190 if ( empty( $attrs['url'] ) ) {
191 return null;
192 }
193
194 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
195
196 $wrapper_attrs = get_block_wrapper_attributes(
197 array(
198 'aria-label' => __( 'Reply', 'activitypub' ),
199 'class' => 'activitypub-reply-block',
200 'data-in-reply-to' => $attrs['url'],
201 )
202 );
203
204 $html = '<div ' . $wrapper_attrs . '>';
205
206 // Try to get and append the embed if requested.
207 $embed = null;
208 if ( $show_embed ) {
209 $embed = wp_oembed_get( $attrs['url'] );
210 if ( $embed ) {
211 $html .= $embed;
212 \wp_enqueue_script( 'wp-embed' );
213 }
214 }
215
216 // Show the link if embed is not requested or if embed failed.
217 if ( ! $show_embed || ! $embed ) {
218 $html .= sprintf(
219 '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
220 esc_url( $attrs['url'] ),
221 esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
222 // translators: %s is the URL of the post being replied to.
223 sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
224 );
225 }
226
227 $html .= '</div>';
228
229 return $html;
230 }
231
232 /**
233 * Renders a modal component that can be used by different blocks.
234 *
235 * @param array $args Arguments for the modal.
236 */
237 public static function render_modal( $args = array() ) {
238 $defaults = array(
239 'content' => '',
240 'id' => '',
241 'is_compact' => false,
242 'title' => '',
243 );
244
245 $args = \wp_parse_args( $args, $defaults );
246 ?>
247
248 <div
249 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
250 data-wp-bind--hidden="!context.modal.isOpen"
251 data-wp-watch="callbacks.handleModalEffects"
252 role="dialog"
253 aria-modal="true"
254 hidden
255 >
256 <div class="activitypub-modal__frame">
257 <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) ) : ?>
258 <div class="activitypub-modal__header">
259 <h2
260 class="activitypub-modal__title"
261 <?php if ( ! empty( $args['id'] ) ) : ?>
262 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
263 <?php endif; ?>
264 ><?php echo \esc_html( $args['title'] ); ?></h2>
265 <button
266 type="button"
267 class="activitypub-modal__close wp-element-button wp-block-button__link"
268 data-wp-on--click="actions.closeModal"
269 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
270 >
271 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
272 <path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z"></path>
273 </svg>
274 </button>
275 </div>
276 <?php endif; ?>
277 <div class="activitypub-modal__content">
278 <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
279 </div>
280 </div>
281 </div>
282 <?php
283 }
284
285 /**
286 * Converts content to blocks before saving to the database.
287 *
288 * @param array $data The post data to be inserted.
289 * @param array $post The Mastodon Create activity.
290 *
291 * @return array
292 */
293 public static function filter_import_mastodon_post_data( $data, $post ) {
294 // Convert paragraphs to blocks.
295 \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
296 $blocks = \array_map(
297 function ( $paragraph ) {
298 return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
299 },
300 $matches[0] ?? array()
301 );
302
303 $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
304
305 // Add reply block if it's a reply.
306 if ( ! empty( $post['object']['inReplyTo'] ) ) {
307 $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
308 $data['post_content'] = $reply_block . $data['post_content'];
309 }
310
311 return $data;
312 }
313
314 /**
315 * Add Interactivity directions to the specified element.
316 *
317 * @param string $content The block content.
318 * @param string[] $selector The selector for the element to add directions to.
319 * @param string[] $attributes The attributes to add to the element.
320 *
321 * @return string The updated content.
322 */
323 public static function add_directions( $content, $selector, $attributes ) {
324 $tags = new \WP_HTML_Tag_Processor( $content );
325
326 while ( $tags->next_tag( $selector ) ) {
327 foreach ( $attributes as $key => $value ) {
328 if ( 'class' === $key ) {
329 $tags->add_class( $value );
330 continue;
331 }
332
333 $tags->set_attribute( $key, $value );
334 }
335 }
336
337 return $tags->get_updated_html();
338 }
339
340 /**
341 * Add post transformation callbacks.
342 *
343 * @param object $post The post object.
344 */
345 public static function add_post_transformation_callbacks( $post ) {
346 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
347
348 // Only transform reply link if it's the first block in the post.
349 $blocks = \parse_blocks( $post->post_content );
350 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
351 \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
352 }
353 }
354
355 /**
356 * Remove post transformation callbacks.
357 *
358 * @param string $content The post content.
359 *
360 * @return string The updated content.
361 */
362 public static function remove_post_transformation_callbacks( $content ) {
363 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
364 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
365
366 return $content;
367 }
368
369 /**
370 * Generate HTML @ link for reply block.
371 *
372 * @param string $block_content The block content.
373 * @param array $block The block data.
374 *
375 * @return string The HTML @ link.
376 */
377 public static function generate_reply_link( $block_content, $block ) {
378 // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
379 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
380
381 // Return empty string if no URL is provided.
382 if ( empty( $block['attrs']['url'] ) ) {
383 return '';
384 }
385
386 $url = $block['attrs']['url'];
387
388 // Try to get ActivityPub representation. Is likely already cached.
389 $object = Http::get_remote_object( $url );
390 if ( \is_wp_error( $object ) ) {
391 return '';
392 }
393
394 $author_url = $object['attributedTo'] ?? '';
395 if ( ! $author_url ) {
396 return '';
397 }
398
399 // Fetch author information.
400 $author = Http::get_remote_object( $author_url );
401 if ( \is_wp_error( $author ) ) {
402 return '';
403 }
404
405 // Get webfinger identifier.
406 $webfinger = '';
407 if ( ! empty( $author['webfinger'] ) ) {
408 $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
409 } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
410 // Construct webfinger-style identifier from username and domain.
411 $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
412 $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
413 }
414
415 if ( ! $webfinger ) {
416 return '';
417 }
418
419 // Generate HTML @ link.
420 return \sprintf(
421 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
422 \esc_url( $url ),
423 \esc_attr( $webfinger ),
424 \esc_html( '@' . strtok( $webfinger, '@' ) )
425 );
426 }
427
428 /**
429 * Transform Embed blocks to block level link.
430 *
431 * Remote servers will simply drop iframe elements, rendering incomplete content.
432 *
433 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
434 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
435 *
436 * @param string $block_content The block content (html).
437 * @param object $block The block object.
438 *
439 * @return string A block level link
440 */
441 public static function revert_embed_links( $block_content, $block ) {
442 if ( ! isset( $block['attrs']['url'] ) ) {
443 return $block_content;
444 }
445 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
446 }
447 }
448