PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.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-base.php

class-base.php in ActivityPub 9.2.0, at includes/transformer/class-base.php

735 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Base Transformer Class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Transformer;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Activity\Base_Object;
12 use Activitypub\Collection\Actors;
13 use Activitypub\Http;
14
15 use function Activitypub\get_upload_baseurl;
16 use function Activitypub\object_to_uri;
17
18 /**
19 * WordPress Base Transformer.
20 *
21 * Transformers are responsible for transforming WordPress objects into different ActivityPub
22 * Object-Types or Activities.
23 *
24 * @method string|null get_content() Returns the content for the transformed item.
25 * @method string|array|null get_icon() Returns an icon for the transformed item.
26 * @method string|null get_id() Returns the ID for the transformed item.
27 * @method string|null get_name() Returns the name for the transformed item.
28 * @method string|null get_summary() Returns the summary for the transformed item.
29 */
30 abstract class Base {
31 /**
32 * The WP_Post or WP_Comment object.
33 *
34 * This is the source object of the transformer.
35 *
36 * @var \WP_Post|\WP_Comment|Base_Object|string|array|\WP_Term
37 */
38 protected $item;
39
40 /**
41 * The WP_Post or WP_Comment object.
42 *
43 * @var \WP_Post|\WP_Comment
44 */
45 protected $wp_object;
46
47 /**
48 * The content visibility.
49 *
50 * @var string
51 */
52 protected $content_visibility;
53
54 /**
55 * Static function to Transform a WordPress Object.
56 *
57 * This helps to chain the output of the Transformer.
58 *
59 * @param \WP_Post|\WP_Comment|Base_Object|string|array|\WP_term $item The item that should be transformed.
60 *
61 * @return Base
62 */
63 public static function transform( $item ) {
64 return new static( $item );
65 }
66
67 /**
68 * Base constructor.
69 *
70 * @param \WP_Post|\WP_Comment|Base_Object|string|array|\WP_Term $item The item that should be transformed.
71 */
72 public function __construct( $item ) {
73 $this->item = $item;
74 $this->wp_object = $item;
75 }
76
77 /**
78 * Transform all properties with available get(ter) functions.
79 *
80 * @param Base_Object $activity_object The ActivityPub Object.
81 *
82 * @return Base_Object|\WP_Error The transformed ActivityPub Object or WP_Error on failure.
83 */
84 protected function transform_object_properties( $activity_object ) {
85 if ( ! $activity_object || \is_wp_error( $activity_object ) ) {
86 return $activity_object;
87 }
88
89 // Save activity in the context of an activitypub request.
90 \add_filter( 'activitypub_is_activitypub_request', '__return_true' );
91
92 $vars = $activity_object->get_object_var_keys();
93
94 foreach ( $vars as $var ) {
95 $getter = 'get_' . $var;
96
97 if ( \method_exists( $this, $getter ) ) {
98 $value = \call_user_func( array( $this, $getter ) );
99
100 if ( null !== $value ) {
101 $setter = 'set_' . $var;
102
103 /**
104 * Filter the value before it is set to the Activity-Object `$activity_object`.
105 *
106 * @param mixed $value The value that should be set.
107 * @param mixed $item The Object.
108 */
109 $value = \apply_filters( "activitypub_transform_{$setter}", $value, $this->item );
110
111 /**
112 * Filter the value before it is set to the Activity-Object `$activity_object`.
113 *
114 * @param mixed $value The value that should be set.
115 * @param string $var The variable name.
116 * @param mixed $item The Object.
117 */
118 $value = \apply_filters( 'activitypub_transform_set', $value, $var, $this->item );
119
120 \call_user_func( array( $activity_object, $setter ), $value );
121 }
122 }
123 }
124
125 // Remove activity in the context of an activitypub request.
126 \remove_filter( 'activitypub_is_activitypub_request', '__return_true' );
127
128 return $activity_object;
129 }
130
131 /**
132 * Transform the item into an ActivityPub Object.
133 *
134 * @return Base_Object The Activity-Object.
135 */
136 public function to_object() {
137 $activity_object = new Base_Object();
138 $activity_object = $this->transform_object_properties( $activity_object );
139
140 if ( \is_wp_error( $activity_object ) ) {
141 return $activity_object;
142 }
143
144 return $this->set_audience( $activity_object );
145 }
146
147 /**
148 * Get the content visibility.
149 *
150 * @return string The content visibility.
151 */
152 public function get_content_visibility() {
153 if ( ! $this->content_visibility ) {
154 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
155 }
156
157 return $this->content_visibility;
158 }
159
160 /**
161 * Set the content visibility.
162 *
163 * @param string $content_visibility The content visibility.
164 */
165 public function set_content_visibility( $content_visibility ) {
166 $this->content_visibility = $content_visibility;
167
168 return $this;
169 }
170
171 /**
172 * Set the audience.
173 *
174 * @param Base_Object $activity_object The ActivityPub Object.
175 *
176 * @return Base_Object The ActivityPub Object.
177 */
178 protected function set_audience( $activity_object ) {
179 $public = 'https://www.w3.org/ns/activitystreams#Public';
180 $followers = null;
181 $replied_to = null;
182
183 $actor = Actors::get_by_resource( $this->get_attributed_to() );
184 if ( ! \is_wp_error( $actor ) ) {
185 $followers = $actor->get_followers();
186 }
187
188 $mentions = \array_values( $this->get_mentions() );
189
190 if ( $this->get_in_reply_to() ) {
191 $object = Http::get_remote_object( $this->get_in_reply_to() );
192 if ( $object && ! \is_wp_error( $object ) && isset( $object['attributedTo'] ) ) {
193 $replied_to = array( object_to_uri( $object['attributedTo'] ) );
194 }
195 }
196
197 switch ( $this->get_content_visibility() ) {
198 case ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC:
199 $activity_object->add_to( $public );
200 $activity_object->add_cc( $followers );
201 $activity_object->add_cc( $mentions );
202 $activity_object->add_cc( $replied_to );
203 break;
204 case ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC:
205 $activity_object->add_to( $followers );
206 $activity_object->add_to( $mentions );
207 $activity_object->add_to( $replied_to );
208 $activity_object->add_cc( $public );
209 break;
210 case ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE:
211 $activity_object->add_to( $mentions );
212 $activity_object->add_to( $replied_to );
213 }
214
215 return $activity_object;
216 }
217
218 /**
219 * Transform the item to an ActivityPub ID.
220 *
221 * @return string The ID of the WordPress Object.
222 */
223 public function to_id() {
224 /* @var Attachment|Comment|Json|Post|User $this Object transformer. */
225 return $this->get_id();
226 }
227
228 /**
229 * Returns a Tombstone object for the item.
230 *
231 * @return Base_Object The Tombstone object.
232 */
233 public function to_tombstone() {
234 $object = new Base_Object();
235 $object->set_type( 'Tombstone' );
236 $object->set_id( $this->to_id() );
237
238 return $object;
239 }
240
241 /**
242 * Transforms the ActivityPub Object to an Activity
243 *
244 * @param string $type The Activity-Type.
245 *
246 * @return Activity The Activity.
247 */
248 public function to_activity( $type ) {
249 $object = $this->to_object();
250
251 $activity = new Activity();
252 $activity->set_type( $type );
253
254 // Pre-fill the Activity with data (for example, cc and to).
255 $activity->set_object( $object );
256
257 // Use simple Object (only ID-URI) for Like and Announce.
258 if ( 'Like' === $type ) {
259 $activity->set_object( $object->get_id() );
260 }
261
262 return $activity;
263 }
264
265 /**
266 * Returns a generic locale based on the Blog settings.
267 *
268 * @return string The locale of the blog.
269 */
270 protected function get_locale() {
271 $lang = \strtolower( \strtok( \get_locale(), '_-' ) );
272
273 /**
274 * Filter the locale of the post.
275 *
276 * @param string $lang The locale of the post.
277 * @param mixed $item The post object.
278 *
279 * @return string The filtered locale of the post.
280 */
281 return \apply_filters( 'activitypub_locale', $lang, $this->item );
282 }
283
284 /**
285 * Returns the default media type for an Object.
286 *
287 * @return string The media type.
288 */
289 public function get_media_type() {
290 return 'text/html';
291 }
292
293 /**
294 * Returns the content map for the post.
295 *
296 * @return array|null The content map for the post or null if not set.
297 */
298 protected function get_content_map() {
299 if ( ! \method_exists( $this, 'get_content' ) || ! $this->get_content() ) {
300 return null;
301 }
302
303 return array(
304 $this->get_locale() => $this->get_content(),
305 );
306 }
307
308 /**
309 * Returns the name map for the post.
310 *
311 * @return array|null The name map for the post or null if not set.
312 */
313 protected function get_name_map() {
314 if ( ! \method_exists( $this, 'get_name' ) || ! $this->get_name() ) {
315 return null;
316 }
317
318 return array(
319 $this->get_locale() => $this->get_name(),
320 );
321 }
322
323 /**
324 * Returns the summary map for the post.
325 *
326 * @return array|null The summary map for the post or null if not set.
327 */
328 protected function get_summary_map() {
329 if ( ! \method_exists( $this, 'get_summary' ) || ! $this->get_summary() ) {
330 return null;
331 }
332
333 return array(
334 $this->get_locale() => $this->get_summary(),
335 );
336 }
337
338 /**
339 * Returns the tags for the post.
340 *
341 * @return array The tags for the post.
342 */
343 protected function get_tag() {
344 $tags = array();
345 $mentions = $this->get_mentions();
346
347 foreach ( $mentions as $mention => $url ) {
348 $tags[] = array(
349 'type' => 'Mention',
350 'href' => \esc_url_raw( $url ),
351 'name' => \esc_html( $mention ),
352 );
353 }
354
355 return \array_unique( $tags, SORT_REGULAR );
356 }
357
358 /**
359 * Get the attributed to.
360 *
361 * @return string The attributed to.
362 */
363 protected function get_attributed_to() {
364 return null;
365 }
366
367 /**
368 * Extracts mentions from the content.
369 *
370 * @return array The mentions.
371 */
372 protected function get_mentions() {
373 $content = '';
374
375 if ( \method_exists( $this, 'get_content' ) ) {
376 $content = $content . ' ' . $this->get_content();
377 }
378
379 if ( \method_exists( $this, 'get_summary' ) ) {
380 $content = $content . ' ' . $this->get_summary();
381 }
382
383 /**
384 * Filter the mentions in the post content.
385 *
386 * @param array $mentions The mentions.
387 * @param string $content The post content.
388 * @param \WP_Post $post The post object.
389 *
390 * @return array The filtered mentions.
391 */
392 return \apply_filters(
393 'activitypub_extract_mentions',
394 array(),
395 $content,
396 $this->item
397 );
398 }
399
400 /**
401 * Returns the in reply to.
402 *
403 * @return string|array|null The in reply to.
404 */
405 protected function get_in_reply_to() {
406 return null;
407 }
408
409 /**
410 * Parse HTML content for image tags and extract attachment information.
411 *
412 * This method is used by both Post and Comment transformers to find images
413 * embedded in HTML content and extract their attachment IDs and alt text.
414 *
415 * @param array $media The existing media array grouped by type.
416 * @param int $max_images Maximum number of images to extract.
417 * @param string $content The HTML content to parse.
418 *
419 * @return array The updated media array with found images.
420 */
421 protected function parse_html_images( $media, $max_images, $content ) {
422 // If someone calls that function directly, bail.
423 if ( ! \class_exists( '\WP_HTML_Tag_Processor' ) ) {
424 return $media;
425 }
426
427 // Max images can't be negative or zero.
428 if ( $max_images <= 0 ) {
429 return $media;
430 }
431
432 $images = array();
433 $base = get_upload_baseurl();
434 $tags = new \WP_HTML_Tag_Processor( $content );
435
436 // This linter warning is a false positive - we have to re-count each time here as we modify $images.
437 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
438 while ( $tags->next_tag( 'img' ) && ( \count( $images ) <= $max_images ) ) {
439 /**
440 * Filter the image source URL.
441 *
442 * This can be used to modify the image source URL before it is used to
443 * determine the attachment ID.
444 *
445 * @param string $src The image source URL.
446 */
447 $src = \apply_filters( 'activitypub_image_src', $tags->get_attribute( 'src' ) );
448
449 /*
450 * If the img source is in our uploads dir, get the
451 * associated ID. Note: if there's a -500x500
452 * type suffix, we remove it, but we try the original
453 * first in case the original image is actually called
454 * that. Likewise, we try adding the -scaled suffix for
455 * the case that this is a small version of an image
456 * that was big enough to get scaled down on upload:
457 * https://make.wordpress.org/core/2019/10/09/introducing-handling-of-big-images-in-wordpress-5-3/
458 */
459 if ( null !== $src && \str_starts_with( $src, $base ) ) {
460 $img_id = \attachment_url_to_postid( $src );
461
462 if ( 0 === $img_id ) {
463 $count = 0;
464 $src = \strtok( $src, '?' );
465 $img_id = \attachment_url_to_postid( $src );
466 }
467
468 if ( 0 === $img_id ) {
469 $count = 0;
470 $src = \preg_replace( '/-(?:\d+x\d+)(\.[a-zA-Z]+)$/', '$1', $src, 1, $count );
471 if ( $count > 0 ) {
472 $img_id = \attachment_url_to_postid( $src );
473 }
474 }
475
476 if ( 0 === $img_id ) {
477 $src = \preg_replace( '/(\.[a-zA-Z]+)$/', '-scaled$1', $src );
478 $img_id = \attachment_url_to_postid( $src );
479 }
480
481 if ( 0 !== $img_id ) {
482 $images[] = array(
483 'id' => $img_id,
484 'alt' => $tags->get_attribute( 'alt' ),
485 );
486 }
487 }
488 }
489
490 if ( \count( $media['image'] ) <= $max_images ) {
491 $media['image'] = \array_merge( $media['image'], $images );
492 }
493
494 return $media;
495 }
496
497 /**
498 * Transforms a WordPress attachment array to ActivityStreams attachment format.
499 *
500 * @param array $media The WordPress attachment array with 'id', optional 'alt', and optional 'icon'.
501 *
502 * @return array The ActivityStreams attachment array.
503 */
504 protected function transform_attachment( $media ) {
505 if ( ! isset( $media['id'] ) ) {
506 return $media;
507 }
508
509 $id = $media['id'];
510 $attachment = array();
511 $mime_type = \get_post_mime_type( $id );
512 $media_type = \strtok( $mime_type, '/' );
513
514 // Switching on image/audio/video.
515 switch ( $media_type ) {
516 case 'image':
517 $image_size = 'large';
518
519 /**
520 * Filter the image URL returned for each post.
521 *
522 * @param array|false $thumbnail The image URL, or false if no image is available.
523 * @param int $id The attachment ID.
524 * @param string $image_size The image size to retrieve. Set to 'large' by default.
525 */
526 $thumbnail = \apply_filters( 'activitypub_get_image', $this->get_attachment_image_src( $id, $image_size ), $id, $image_size );
527
528 if ( $thumbnail ) {
529 $image = array(
530 'type' => 'Image',
531 'url' => \esc_url_raw( $thumbnail[0] ),
532 'mediaType' => \esc_attr( $mime_type ),
533 );
534
535 if ( ! empty( $media['alt'] ) ) {
536 $image['name'] = \html_entity_decode( \wp_strip_all_tags( $media['alt'] ), ENT_QUOTES, 'UTF-8' );
537 } else {
538 $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
539 if ( $alt ) {
540 $image['name'] = \html_entity_decode( \wp_strip_all_tags( $alt ), ENT_QUOTES, 'UTF-8' );
541 }
542 }
543
544 // Add EXIF metadata using Schema.org exifData property (FEP-ee3a).
545 $exif_data = $this->get_exif_data( $id );
546 if ( $exif_data ) {
547 $image['exifData'] = $exif_data;
548 }
549
550 $attachment = $image;
551 }
552 break;
553
554 case 'audio':
555 case 'video':
556 $meta = \wp_get_attachment_metadata( $id );
557 $attachment = array(
558 'type' => \ucfirst( $media_type ),
559 'mediaType' => \esc_attr( $mime_type ),
560 'url' => \esc_url_raw( \wp_get_attachment_url( $id ) ),
561 'name' => \esc_attr( \get_the_title( $id ) ),
562 );
563
564 // Height and width for videos.
565 if ( isset( $meta['width'], $meta['height'] ) ) {
566 $attachment['width'] = \esc_attr( $meta['width'] );
567 $attachment['height'] = \esc_attr( $meta['height'] );
568 }
569
570 // Use poster image from the block, or fall back to the transformer icon.
571 if ( ! empty( $media['icon'] ) ) {
572 $attachment['icon'] = \esc_url_raw( $media['icon'] );
573 } elseif ( \method_exists( $this, 'get_icon' ) && $this->get_icon() ) {
574 $attachment['icon'] = object_to_uri( $this->get_icon() );
575 }
576 break;
577 }
578
579 /**
580 * Filter the attachment for a post.
581 *
582 * @param array $attachment The attachment.
583 * @param int $id The attachment ID.
584 *
585 * @return array The filtered attachment.
586 */
587 return \apply_filters( 'activitypub_attachment', $attachment, $id );
588 }
589
590 /**
591 * Return details about an image attachment.
592 *
593 * @param int $id The attachment ID.
594 * @param string $image_size The image size to retrieve. Set to 'large' by default.
595 *
596 * @return array|false Array of image data, or boolean false if no image is available.
597 */
598 protected function get_attachment_image_src( $id, $image_size = 'large' ) {
599 /**
600 * Hook into the image retrieval process. Before image retrieval.
601 *
602 * @param int $id The attachment ID.
603 * @param string $image_size The image size to retrieve. Set to 'large' by default.
604 */
605 \do_action( 'activitypub_get_image_pre', $id, $image_size );
606
607 $image = \wp_get_attachment_image_src( $id, $image_size );
608
609 /**
610 * Hook into the image retrieval process. After image retrieval.
611 *
612 * @param int $id The attachment ID.
613 * @param string $image_size The image size to retrieve. Set to 'large' by default.
614 */
615 \do_action( 'activitypub_get_image_post', $id, $image_size );
616
617 return $image;
618 }
619
620 /**
621 * Get EXIF metadata for an image attachment using Schema.org exifData property.
622 *
623 * Returns an array of PropertyValue objects as defined in FEP-ee3a.
624 *
625 * @link https://codeberg.org/fediverse/fep/src/branch/main/fep/ee3a/fep-ee3a.md
626 *
627 * @param int $attachment_id The attachment ID.
628 *
629 * @return array|null Array of PropertyValue objects or null if no EXIF data available.
630 */
631 protected function get_exif_data( $attachment_id ) {
632 $metadata = \wp_get_attachment_metadata( $attachment_id );
633
634 if ( empty( $metadata['image_meta'] ) ) {
635 return null;
636 }
637
638 $image_meta = $metadata['image_meta'];
639 $exif_data = array();
640
641 // Map WordPress image_meta to FEP-ee3a EXIF field names.
642 if ( ! empty( $image_meta['created_timestamp'] ) ) {
643 $exif_data[] = array(
644 '@type' => 'PropertyValue',
645 'name' => 'DateTime',
646 'value' => \gmdate( 'Y:m:d H:i:s', (int) $image_meta['created_timestamp'] ),
647 );
648 }
649
650 if ( ! empty( $image_meta['shutter_speed'] ) ) {
651 $shutter_speed = (float) $image_meta['shutter_speed'];
652 // Format shutter speed as a fraction (e.g., "1/100") for speeds faster than 1 second.
653 if ( $shutter_speed > 0 && $shutter_speed < 1 ) {
654 $value = '1/' . \round( 1 / $shutter_speed );
655 } elseif ( $shutter_speed >= 1 ) {
656 $value = (string) $shutter_speed;
657 }
658 if ( isset( $value ) ) {
659 $exif_data[] = array(
660 '@type' => 'PropertyValue',
661 'name' => 'ExposureTime',
662 'value' => $value,
663 );
664 }
665 }
666
667 if ( ! empty( $image_meta['aperture'] ) ) {
668 $exif_data[] = array(
669 '@type' => 'PropertyValue',
670 'name' => 'FNumber',
671 'value' => 'f/' . (float) $image_meta['aperture'],
672 );
673 }
674
675 if ( ! empty( $image_meta['focal_length'] ) ) {
676 $exif_data[] = array(
677 '@type' => 'PropertyValue',
678 'name' => 'FocalLength',
679 'value' => (string) (float) $image_meta['focal_length'],
680 );
681 }
682
683 if ( ! empty( $image_meta['iso'] ) ) {
684 $exif_data[] = array(
685 '@type' => 'PropertyValue',
686 'name' => 'PhotographicSensitivity',
687 'value' => (string) (int) $image_meta['iso'],
688 );
689 }
690
691 if ( ! empty( $image_meta['camera'] ) ) {
692 $exif_data[] = array(
693 '@type' => 'PropertyValue',
694 'name' => 'Model',
695 'value' => \sanitize_text_field( $image_meta['camera'] ),
696 );
697 }
698
699 /**
700 * Filter the EXIF data for an image attachment.
701 *
702 * @param array $exif_data Array of PropertyValue objects for Schema.org exifData.
703 * @param array $image_meta The WordPress image_meta array.
704 * @param int $attachment_id The attachment ID.
705 *
706 * @return array The filtered EXIF data array.
707 */
708 $exif_data = \apply_filters( 'activitypub_image_exif', $exif_data, $image_meta, $attachment_id );
709
710 return ! empty( $exif_data ) ? $exif_data : null;
711 }
712
713 /**
714 * Filter attachments to ensure uniqueness based on their ID.
715 *
716 * @param array $attachments Array of attachments with 'id' field.
717 *
718 * @return array Array with duplicate attachments removed.
719 */
720 protected function filter_unique_attachments( $attachments ) {
721 $seen_ids = array();
722
723 return \array_filter(
724 $attachments,
725 static function ( $attachment ) use ( &$seen_ids ) {
726 if ( isset( $attachment['id'] ) && ! \in_array( $attachment['id'], $seen_ids, true ) ) {
727 $seen_ids[] = $attachment['id'];
728 return true;
729 }
730 return false;
731 }
732 );
733 }
734 }
735