From 6c6d9076a801cf417e55b00fc17bc219fb40bf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Menrath?= Date: Fri, 22 Dec 2023 16:18:18 +0100 Subject: [PATCH] WIP: Rewrite of transformer management --- includes/activity/class-base-object.php | 131 ++++++++++- includes/activity/class-event.php | 128 +++++++++++ includes/activity/class-place.php | 94 ++++++++ includes/activity/class-postal-address.php | 62 ++++++ includes/class-activity-dispatcher.php | 4 +- includes/class-migration.php | 16 ++ includes/transformer/class-attachment.php | 18 ++ includes/transformer/class-base.php | 34 ++- includes/transformer/class-factory.php | 245 ++++++++++++++++++++- includes/transformer/class-post.php | 18 ++ templates/comment-json.php | 2 +- templates/post-json.php | 3 +- 12 files changed, 743 insertions(+), 12 deletions(-) create mode 100644 includes/activity/class-event.php create mode 100644 includes/activity/class-place.php create mode 100644 includes/activity/class-postal-address.php diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 1e256cb..e127ca0 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -22,10 +22,13 @@ use function Activitypub\snake_to_camel_case; * * Note: Object is a reserved keyword in PHP. It has been suffixed with * 'Base_' for this reason. - * + * * @see https://www.w3.org/TR/activitystreams-core/#object */ class Base_Object { + const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams'; + const CONTEXT_SECURITY = 'https://w3id.org/security/v1'; + /** * The object's unique global identifier * @@ -457,7 +460,7 @@ class Base_Object { } if ( \strncasecmp( $method, 'set', 3 ) === 0 ) { - $this->set( $var, $params[0] ); + return $this->set( $var, $params[0] ); } if ( \strncasecmp( $method, 'add', 3 ) === 0 ) { @@ -465,6 +468,13 @@ class Base_Object { } } + /** + * The ActivityPub object typicallly can define it's own JSON-LD context. + */ + public function __construct() { + $this->context = $this->get_context(); + } + /** * Magic function, to transform the object to string. * @@ -505,7 +515,10 @@ class Base_Object { * * @return boolean True if the object has the key. */ - public function has( $key ) { + public function has( $key ) { + if ( $key === 'actor ') { + $key = 'actor'; + } return property_exists( $this, $key ); } @@ -519,12 +532,16 @@ class Base_Object { */ public function set( $key, $value ) { if ( ! $this->has( $key ) ) { + if ( $key === 'actor' ) { + $key = 'actor'; + } return new WP_Error( 'invalid_key', __( 'Invalid key', 'activitypub' ), array( 'status' => 404 ) ); } + $this->$key = $value; - return $this->$key; + return $this; } /** @@ -692,4 +709,110 @@ class Base_Object { public function get_object_var_keys() { return \array_keys( \get_object_vars( $this ) ); } + + /** + * Get the context information for a property. + * + * @param string $property + * + * @return array|null + */ + private function get_property_context( string $property ) { + $reflection_class = new \ReflectionClass( $this ); + + if ( $reflection_class->hasProperty( $property ) ) { + $reflection_property = $reflection_class->getProperty( $property ); + $doc_omment = $reflection_property->getDocComment(); + + // Extract context information from the doc comment. + preg_match( '/@context\s+([^\s]+)/', $doc_omment, $matches ); + + if ( ! empty( $matches[1] ) ) { + return $matches[1]; + } else { + return self::CONTEXT_ACTIVITYSTREAMS; + } + } + + return null; + } + + /** + * Compact the context via the JSON-LD syntax. + * + * @param array $key_context A certain part of the whole context. + * @param array $array The full URIs of the contexts. + * @param array $abbreviation The short name of the context. + * + * @return array $key_kontext The compacted context. + */ + private static function compact_context( $key_context, $namespace, $abbreviation ) { + $abbreviation_added = false; + foreach ( $key_context as $key => $value ) { + // Check if the key starts with the namespace + if ( strpos( $value, $namespace ) === 0 ) { + // Replace the key + $key_context[ $key ] = $abbreviation . ':' . substr( $value, strlen( $namespace ) ); + + // Add abbreviation element for the namespace only once + if ( ! $abbreviation_added ) { + $key_context = array( $abbreviation => $namespace ) + $key_context; + $abbreviation_added = true; + } + } + } + return $key_context; + } + + /** + * Builds the context of the ActivityPub object via the PHP docs. + * + * @return array $context A shortened and clean JSON-LD context for the ActivityPub object. + */ + public static function get_context() { + $class = static::class; + + // Try to lookup the context from a transient. + $transient = "activitypub_json_context_object_{$class}"; + $context = get_transient( $transient ); + if ( $context ) { + return $context; + } + $reflection_class = new \ReflectionClass( static::class ); + $context = array( + self::CONTEXT_ACTIVITYSTREAMS, + self::CONTEXT_SECURITY, + ); + + $key_context = array(); + + foreach ( $reflection_class->getProperties() as $property ) { + $doc_omment = $property->getDocComment(); + + // Extract context information from the doc comment. + preg_match( '/@context\s+([^\s]+)/', $doc_omment, $matches ); + + if ( ! empty( $matches[1] ) ) { + $key_context[ snake_to_camel_case( $property->name ) ] = $matches[1]; + } + } + + $namespace_abbreviations = array( + 'https://joinpeertube.org/ns#' => 'pt', + 'https://joinmobilizon.org/ns#' => 'mz', + 'https://schema.org/' => 'sc', + 'https://www.w3.org/2002/12/cal/ical#' => 'ical', + ); + + foreach ( $namespace_abbreviations as $namespace => $abbreviation ) { + $key_context = self::compact_context( $key_context, $namespace, $abbreviation ); + } + + if ( ! empty( $key_context ) ) { + $context[] = $key_context; + } + + set_transient( $transient, $context ); + return $context; + } } diff --git a/includes/activity/class-event.php b/includes/activity/class-event.php new file mode 100644 index 0000000..f80f6d2 --- /dev/null +++ b/includes/activity/class-event.php @@ -0,0 +1,128 @@ + + * Mobilizon also implemented this as a fallback to their own + * repliesModerationOption. + * + * @context https://joinpeertube.org/ns#commentsEnabled + * @see https://docs.joinpeertube.org/api/activitypub#video + * @see https://docs.joinmobilizon.org/contribute/activity_pub/ + * @var bool|null + */ + protected $comments_enabled; + + /** + * @context https://joinmobilizon.org/ns#timezone + * @var string + */ + protected $timezone; + + /** + * @context https://joinmobilizon.org/ns#repliesModerationOption + * @see https://docs.joinmobilizon.org/contribute/activity_pub/#repliesmoderation + * @var string + */ + protected $replies_moderation_option; + + /** + * @context https://joinmobilizon.org/ns#anonymousParticipationEnabled + * @see https://docs.joinmobilizon.org/contribute/activity_pub/#anonymousparticipationenabled + * @var bool + */ + protected $anonymous_participation_enabled; + + /** + * @context https://schema.org/category + * @var enum + */ + protected $category; + + /** + * @context https://schema.org/inLanguage + * @var + */ + protected $in_language; + + /** + * @context https://joinmobilizon.org/ns#isOnline + * @var bool + */ + protected $is_online; + + /** + * @context https://www.w3.org/2002/12/cal/ical#status + * @var enum + */ + protected $status; + + /** + * @context https://joinmobilizon.org/ns#externalParticipationUrl + * @var string + */ + protected $external_participation_url; + + /** + * @context https://joinmobilizon.org/ns#joinMode + * @see https://docs.joinmobilizon.org/contribute/activity_pub/#joinmode + * @var + */ + protected $join_mode; + + /** + * @context https://joinmobilizon.org/ns#participantCount + * @var int + */ + protected $participant_count; + + /** + * @context https://schema.org/maximumAttendeeCapacity + * @see https://docs.joinmobilizon.org/contribute/activity_pub/#maximumattendeecapacity + * @var int + */ + protected $maximum_attendee_capacity; + + /** + * @context https://schema.org/remainingAttendeeCapacity + * @see https://docs.joinmobilizon.org/contribute/activity_pub/#remainignattendeecapacity + * @var int + */ + protected $remaining_attendee_capacity; +} diff --git a/includes/activity/class-place.php b/includes/activity/class-place.php new file mode 100644 index 0000000..3e946da --- /dev/null +++ b/includes/activity/class-place.php @@ -0,0 +1,94 @@ += 0.0f, <= 100.0f] + */ + protected $accuracy; + + /** + * Indicates the altitude of a place. The measurement units is indicated using the units property. + * If units is not specified, the default is assumed to be "m" indicating meters. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-altitude + * @var float xsd:float + */ + protected $altitude; + + /** + * The latitude of a place. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-latitude + * @var float xsd:float + */ + protected $latitude; + + /** + * The longitude of a place. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-longitude + * @var float xsd:float + */ + protected $longitude; + + /** + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-radius + * @var float + */ + protected $radius; + + /** + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-units + * @var string + */ + protected $units; + + /** + * Extension invented by PeerTube whether comments/replies are + * Mobilizon also implemented this as a fallback to their own + * repliesModerationOption. + * + * @see https://docs.joinpeertube.org/api/activitypub#video + * @see https://docs.joinmobilizon.org/contribute/activity_pub/ + * + * @var bool + */ + protected $comments_enabled; + + /** + * @var Postal_Address|string + */ + protected $address; +} diff --git a/includes/activity/class-postal-address.php b/includes/activity/class-postal-address.php new file mode 100644 index 0000000..6e7782a --- /dev/null +++ b/includes/activity/class-postal-address.php @@ -0,0 +1,62 @@ +get_transformer( $wp_object ); if ( null !== $user_id ) { $transformer->change_wp_user_id( $user_id ); @@ -106,7 +106,7 @@ class Activity_Dispatcher { return; } - $transformer = Factory::get_transformer( $wp_object ); + $transformer = Factory::instance()->get_transformer( $wp_object ); $transformer->change_wp_user_id( Users::BLOG_USER_ID ); $user_id = $transformer->get_wp_user_id(); diff --git a/includes/class-migration.php b/includes/class-migration.php index adebb7e..252d181 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -197,4 +197,20 @@ class Migration { wp_cache_delete( sprintf( Followers::CACHE_KEY_INBOXES, $user_id ), 'activitypub' ); } } + + /** + * Updates the supported post type settings to the mapped transformer setting. + * TODO: Test this + * @return void + */ + private static function migrate_from_version_number_transformer_management_placeholder() { + $supported_post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ); + Admin::register_settings(); + $transformer_mapping = array(); + foreach ( $supported_post_types as $supported_post_type ) { + $transformer_mapping[ $supported_post_type ] = 'activitypub/post'; + } + update_option( 'activitypub_transformer_mapping', $transformer_mapping ); + } + } diff --git a/includes/transformer/class-attachment.php b/includes/transformer/class-attachment.php index 684d2eb..747a250 100644 --- a/includes/transformer/class-attachment.php +++ b/includes/transformer/class-attachment.php @@ -14,6 +14,24 @@ use Activitypub\Transformer\Post; * - Activitypub\Activity\Base_Object */ class Attachment extends Post { + /** + * Getter function for the name of the transformer. + * + * @return string name + */ + public function get_name() { + return 'activitypub/attachment'; + } + + /** + * Getter function for the display name (label/title) of the transformer. + * + * @return string name + */ + public function get_label() { + return 'Built-In Transformer for WordPress Attachments'; + } + /** * Generates all Media Attachments for a Post. * diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 2d2c59b..89c6090 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -18,6 +18,8 @@ abstract class Base { */ protected $object; + protected $activitypub_object_type = 'Base_Object'; + /** * Static function to Transform a WordPress Object. * @@ -36,8 +38,10 @@ abstract class Base { * * @param stdClass $object */ - public function __construct( $object ) { - $this->object = $object; + public function __construct( $object=null ) { + if ( $object ) { + $this->object = $object; + } } /** @@ -102,4 +106,30 @@ abstract class Base { * @return int The User-ID of the WordPress Post */ abstract public function change_wp_user_id( $user_id ); + + /** + * Get the name used for registering the transformer with the ActivityPub plugin. + * + * @since version_number_transformer_management_placeholder + * @return string name + */ + abstract public function get_name(); + + /** + * Get the display name for the ActivityPub transformer. + * + * @since version_number_transformer_management_placeholder + * @return string display name + */ + abstract public function get_label(); + + /** + * Returns the ActivityStreams 2.0 Object-Type for a Post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + * + * @return string The Object-Type. + */ + abstract protected function get_object_type(); + } diff --git a/includes/transformer/class-factory.php b/includes/transformer/class-factory.php index 7df7836..727e239 100644 --- a/includes/transformer/class-factory.php +++ b/includes/transformer/class-factory.php @@ -9,7 +9,214 @@ use Activitypub\Transformer\Attachment; * Transformer Factory */ class Factory { - public static function get_transformer( $object ) { + const DEFAULT_TRANSFORMER_MAPPING = array( + 'post' => 'activitypub/post', + 'page' => 'activitypub/post', + 'attachment' => 'activitypub/attachment', + ); + + /** + * Transformers. + * + * Holds the list of all the ActivityPub transformers. Default is `null`. + * + * @since version_number_transformer_management_placeholder + * @access private + * + * @var \ActivityPub\Transformer\Base[] + */ + private $transformers = null; + + /** + * F instance. + * + * Holds the transformer instance. + * + * @since version_number_transformer_management_placeholder + * @access protected + */ + protected static $_instances = []; + + + /** + * Instance. + * + * Ensures only one instance of the transformer manager class is loaded or can be loaded. + * + * @since version_number_transformer_management_placeholder + * @access public + * @static + * + * @return Transformer_Manager An instance of the class. + */ + public static function instance() { + $class_name = static::class_name(); + + if ( empty( static::$_instances[ $class_name ] ) ) { + static::$_instances[ $class_name ] = new static(); + } + + return static::$_instances[ $class_name ]; + } + + /** + * Class name. + * + * Retrieve the name of the class. + * + * @since version_number_transformer_management_placeholder + * @access public + * @static + */ + public static function class_name() { + return get_called_class(); + } + + /** + * Checks if a transformer is registered. + * + * @since version_number_transformer_management_placeholder + * + * @param string $name Transformer name including namespace. + * @return bool True if the block type is registered, false otherwise. + */ + public function is_registered( $name ) { + return isset( $this->transformers[ $name ] ); + } + + /** + * Register a transformer. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param \ActivityPub\Transformer\Base $transformer_instance ActivityPub Transformer. + * + * @return bool True if the ActivityPub transformer was registered. + */ + public function register( \ActivityPub\Transformer\Base $transformer_instance ) { + + if ( ! $transformer_instance instanceof \ActivityPub\Transformer\Base ) { + _doing_it_wrong( + __METHOD__, + \esc_html__( 'ActivityPub transformer instance must be a of \ActivityPub\Transformer_Base class.' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + $transformer_name = $transformer_instance->get_name(); + if ( preg_match( '/[A-Z]+/', $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + \esc_html__( 'ActivityPub transformer names must not contain uppercase characters.' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + $name_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/'; + if ( ! preg_match( $name_matcher, $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + \esc_html__( 'ActivityPub transformer names must contain a namespace prefix. Example: my-plugin/my-custom-transformer' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + if ( $this->is_registered( $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Block name. */ + sprintf( 'ActivityPub transformer with name "%s" is already registered.', esc_html( $transformer_name ) ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + /** + * Should the ActivityPub transformer be registered. + * + * @since version_number_transformer_management_placeholder + * + * @param bool $should_register Should the ActivityPub transformer be registered. Default is `true`. + * @param \ActivityPub\Transformer\Base $transformer_instance Widget instance. + */ + $should_register = apply_filters( 'activitypub_transformer_is_enabled', true, $transformer_instance ); + + if ( ! $should_register ) { + return false; + } + + $this->transformers[ $transformer_name ] = $transformer_instance; + + return true; + } + + /** + * Init transformers. + * + * Initialize ActivityPub transformer manager. + * Include the builtin transformers by default and add third party ones. + * + * @since version_number_transformer_management_placeholder + * @access private + */ + private function init_transformers() { + $builtin_transformers = [ + 'post', + 'attachment' + ]; + + $this->transformers = []; + + foreach ( $builtin_transformers as $transformer_name ) { + $class_name = ucfirst( $transformer_name ); + + $class_name = '\Activitypub\Transformer\\' . $class_name; + + $this->register( new $class_name() ); + } + + /** + * Let other transformers register. + * + * Fires after the built-in Activitypub transformers are registered. + * + * @since version_number_transformer_management_placeholder + * + * @param Transformer_Factory $this The widgets manager. + */ + do_action( 'activitypub_transformers_register', $this ); + } + + /** + * Get available ActivityPub transformers. + * + * Retrieve the registered transformers list. If given a transformer name + * it returns the given transformer if it is registered. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param string $transformer_name Optional. Transformer name. Default is null. + * + * @return Base|Base[]|null Registered transformers. + */ + public function get_transformer_class( $transformer_name = null ) { + if ( is_null( $this->transformers ) ) { + $this->init_transformers(); + } + + if ( null !== $transformer_name ) { + return isset( $this->transformers[ $transformer_name ] ) ? $this->transformers[ $transformer_name ] : null; + } + + return $this->transformers; + } + + public static function get_default_transformer( $object ) { /** * Filter the transformer for a given object. * @@ -58,4 +265,40 @@ class Factory { return null; } } + + /** + * Get the mapped ActivityPub transformer. + * + * Returns a new instance of the needed WordPress to ActivityPub transformer. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param WP_Post|WP_Comment $object The WordPress Post/Comment. + * + * @return \ActivityPub\Transformer\Base|null Registered transformers. + */ + public function get_transformer( $object ) { + switch ( get_class( $object ) ) { + case 'WP_Post': + $post_type = get_post_type( $object ); + $transformer_mapping = \get_option( 'activitypub_transformer_mapping', self::DEFAULT_TRANSFORMER_MAPPING ); + $transformer_name = $transformer_mapping[ $post_type ]; + if ( $transformer_name ) { + $transformer_class = $this->get_transformer_class( $transformer_name ); + } else { + return self::get_default_transformer( $object ); + } + + if ( $transformer_class ) { + return new $transformer_class( $object ); + } else { + return self::get_default_transformer( $object ); + } + case 'WP_Comment': + return new Comment( $object ); + default: + self::get_default_transformer( $object ); + } + } } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 7e631dd..b5cdd8d 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -24,6 +24,24 @@ use function Activitypub\site_supports_blocks; * - Activitypub\Activity\Base_Object */ class Post extends Base { + /** + * Getter function for the name of the transformer. + * + * @return string name + */ + public function get_name() { + return 'activitypub/post'; + } + + /** + * Getter function for the display name (label/title) of the transformer. + * + * @return string name + */ + public function get_label() { + return 'Built-In Transformer for WordPress Posts'; + } + /** * Returns the ID of the WordPress Post. * diff --git a/templates/comment-json.php b/templates/comment-json.php index aea4268..2f9f298 100644 --- a/templates/comment-json.php +++ b/templates/comment-json.php @@ -1,7 +1,7 @@ get_transformer( $comment ); $json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() ); // filter output diff --git a/templates/post-json.php b/templates/post-json.php index 1db29a6..0395422 100644 --- a/templates/post-json.php +++ b/templates/post-json.php @@ -2,8 +2,7 @@ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $post = \get_post(); -$post_object = \Activitypub\Transformer\Factory::get_transformer( $post )->to_object(); -$post_object->set_context( \Activitypub\get_context() ); +$post_object = \Activitypub\Transformer\Factory::instance()->get_transformer( $post )->to_object(); /* * Action triggerd prior to the ActivityPub profile being created and sent to the client