From feb5a196bfd1c9606ee065fb699eab944f9c71f6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 17:56:18 +0100 Subject: [PATCH 1/3] Add WordPress Abilities API integration Register ActivityPub abilities (discovery, social) for the WordPress Abilities API (WP 6.9+), enabling other plugins to discover and call ActivityPub operations without depending on internal class structures. --- activitypub.php | 4 + includes/ability/class-actor.php | 145 ++++++++++++ includes/ability/class-followers.php | 162 +++++++++++++ includes/ability/class-following.php | 340 +++++++++++++++++++++++++++ includes/ability/class-webfinger.php | 87 +++++++ includes/class-abilities.php | 98 ++++++++ 6 files changed, 836 insertions(+) create mode 100644 includes/ability/class-actor.php create mode 100644 includes/ability/class-followers.php create mode 100644 includes/ability/class-following.php create mode 100644 includes/ability/class-webfinger.php create mode 100644 includes/class-abilities.php diff --git a/activitypub.php b/activitypub.php index 6bdfd0458..c8f9405fa 100644 --- a/activitypub.php +++ b/activitypub.php @@ -107,6 +107,10 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Relay', 'init' ) ); } + // WordPress Abilities API (WP 6.9+). These hooks only fire when the API is available. + \add_action( 'wp_abilities_api_categories_init', array( __NAMESPACE__ . '\Abilities', 'register_categories' ) ); + \add_action( 'wp_abilities_api_init', array( __NAMESPACE__ . '\Abilities', 'register_abilities' ) ); + // Load development tools. if ( 'local' === wp_get_environment_type() ) { $loader_file = __DIR__ . '/local/load.php'; diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php new file mode 100644 index 000000000..455746ab7 --- /dev/null +++ b/includes/ability/class-actor.php @@ -0,0 +1,145 @@ + \__( 'Get Actor Info', 'activitypub' ), + 'description' => \__( 'Fetch profile information for a remote ActivityPub actor.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'get_actor_info' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'summary' => array( + 'type' => 'string', + ), + 'inbox' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'outbox' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get actor information. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_actor_info( $input ) { + $actor_input = \sanitize_text_field( $input['actor'] ); + + $post = Remote_Actors::fetch_by_various( $actor_input ); + if ( \is_wp_error( $post ) ) { + return $post; + } + + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + return array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'summary' => $actor->get_summary(), + 'inbox' => $actor->get_inbox(), + 'outbox' => $actor->get_outbox(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } +} diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php new file mode 100644 index 000000000..a390b5414 --- /dev/null +++ b/includes/ability/class-followers.php @@ -0,0 +1,162 @@ + \__( 'Get Followers', 'activitypub' ), + 'description' => \__( 'List followers for a local actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'get_followers' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID.', 'activitypub' ), + ), + 'page' => array( + 'type' => 'integer', + 'description' => \__( 'Page number for pagination.', 'activitypub' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => \__( 'Number of results per page.', 'activitypub' ), + ), + ), + 'required' => array( 'user_id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'followers' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get followers for a local actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_followers( $input ) { + $user_id = \absint( $input['user_id'] ); + + if ( ! $user_id ) { + return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); + } + + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; + $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + + $data = Followers_Collection::query( $user_id, $per_page, $page ); + + $followers = array(); + foreach ( $data['followers'] as $post ) { + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + continue; + } + $followers[] = array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } + + return array( + 'followers' => $followers, + 'total' => $data['total'], + ); + } +} diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php new file mode 100644 index 000000000..1b8d45e18 --- /dev/null +++ b/includes/ability/class-following.php @@ -0,0 +1,340 @@ + \__( 'Get Following', 'activitypub' ), + 'description' => \__( 'List accounts being followed by a local actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'get_following' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID.', 'activitypub' ), + ), + 'page' => array( + 'type' => 'integer', + 'description' => \__( 'Page number for pagination.', 'activitypub' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => \__( 'Number of results per page.', 'activitypub' ), + ), + ), + 'required' => array( 'user_id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'following' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Register the follow ability. + * + * @since unreleased + */ + private static function register_follow() { + \wp_register_ability( + 'activitypub/follow', + array( + 'label' => \__( 'Follow', 'activitypub' ), + 'description' => \__( 'Follow a remote actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'follow' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle to follow.', 'activitypub' ), + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID. Defaults to the current user.', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'outbox_item_id' => array( + 'type' => 'integer', + ), + 'status' => array( + 'type' => 'string', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Register the unfollow ability. + * + * @since unreleased + */ + private static function register_unfollow() { + \wp_register_ability( + 'activitypub/unfollow', + array( + 'label' => \__( 'Unfollow', 'activitypub' ), + 'description' => \__( 'Unfollow a remote actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'unfollow' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle to unfollow.', 'activitypub' ), + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID. Defaults to the current user.', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( + 'type' => 'boolean', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get following for a local actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_following( $input ) { + $user_id = \absint( $input['user_id'] ); + + if ( ! $user_id ) { + return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); + } + + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; + $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + + $data = Following_Collection::query( $user_id, $per_page, $page ); + + $following = array(); + foreach ( $data['following'] as $post ) { + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + continue; + } + $following[] = array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } + + return array( + 'following' => $following, + 'total' => $data['total'], + ); + } + + /** + * Follow a remote actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function follow( $input ) { + $actor = \sanitize_text_field( $input['actor'] ); + $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); + + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $result = follow( $actor, $user_id ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + return array( + 'outbox_item_id' => $result, + 'status' => 'pending', + ); + } + + /** + * Unfollow a remote actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function unfollow( $input ) { + $actor = \sanitize_text_field( $input['actor'] ); + $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); + + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $result = unfollow( $actor, $user_id ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + ); + } +} diff --git a/includes/ability/class-webfinger.php b/includes/ability/class-webfinger.php new file mode 100644 index 000000000..7d505c3a6 --- /dev/null +++ b/includes/ability/class-webfinger.php @@ -0,0 +1,87 @@ + \__( 'Resolve WebFinger Handle', 'activitypub' ), + 'description' => \__( 'Resolve a WebFinger handle to an ActivityPub actor URL.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'resolve_handle' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'handle' => array( + 'type' => 'string', + 'description' => \__( 'WebFinger handle (e.g., user@example.com)', 'activitypub' ), + ), + ), + 'required' => array( 'handle' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Resolve a WebFinger handle to an actor URL. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function resolve_handle( $input ) { + $handle = \sanitize_text_field( $input['handle'] ); + + return Webfinger_Util::get_data( $handle ); + } +} diff --git a/includes/class-abilities.php b/includes/class-abilities.php new file mode 100644 index 000000000..267f6680b --- /dev/null +++ b/includes/class-abilities.php @@ -0,0 +1,98 @@ + \__( 'Discovery', 'activitypub' ), + 'description' => \__( 'Look up and discover remote actors in the Fediverse.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-social', + array( + 'label' => \__( 'Social', 'activitypub' ), + 'description' => \__( 'Manage followers, following, and social connections.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-publish', + array( + 'label' => \__( 'Publish', 'activitypub' ), + 'description' => \__( 'Publish and share content to the Fediverse.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-moderation', + array( + 'label' => \__( 'Moderation', 'activitypub' ), + 'description' => \__( 'Moderate actors, domains, and activity delivery.', 'activitypub' ), + ) + ); + + /** + * Fires after built-in ability categories are registered. + * + * Use this hook to register additional ability categories. + * + * @since unreleased + */ + \do_action( 'activitypub_register_ability_categories' ); + } + + /** + * Register all ActivityPub abilities. + * + * Hooked into `wp_abilities_api_init`. + * + * @since unreleased + */ + public static function register_abilities() { + Actor::register(); + Followers::register(); + Following::register(); + Webfinger::register(); + + /** + * Fires after built-in abilities are registered. + * + * Use this hook to register additional abilities. + * + * @since unreleased + */ + \do_action( 'activitypub_register_abilities' ); + } +} From 658bb639bc4ece5c3ae32f908043172b2d151fe1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 18:00:08 +0100 Subject: [PATCH 2/3] Remove unused $input parameter from permission callbacks --- includes/ability/class-actor.php | 3 +-- includes/ability/class-followers.php | 3 +-- includes/ability/class-following.php | 3 +-- includes/ability/class-webfinger.php | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php index 455746ab7..85c6fb65e 100644 --- a/includes/ability/class-actor.php +++ b/includes/ability/class-actor.php @@ -101,10 +101,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php index a390b5414..663aa393a 100644 --- a/includes/ability/class-followers.php +++ b/includes/ability/class-followers.php @@ -110,10 +110,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php index 1b8d45e18..142070716 100644 --- a/includes/ability/class-following.php +++ b/includes/ability/class-following.php @@ -225,10 +225,9 @@ private static function register_unfollow() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-webfinger.php b/includes/ability/class-webfinger.php index 7d505c3a6..f6e2ae60c 100644 --- a/includes/ability/class-webfinger.php +++ b/includes/ability/class-webfinger.php @@ -64,10 +64,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } From 80c297953e916e5c401efeca817766e8cc2a6c1a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 24 Feb 2026 11:31:16 +0100 Subject: [PATCH 3/3] Fix ability security issues and clean up naming - Gate follow/unfollow abilities behind `activitypub_following_ui` option - Require `manage_options` for cross-user operations (follow, unfollow, get-following) - Rename `activitypub/get-actor-info` to `activitypub/get-actor` - Remove empty placeholder categories (publish, moderation) --- ABILITIES-API.md | 163 +++++++++++++++++++++++++++ includes/ability/class-actor.php | 4 +- includes/ability/class-following.php | 28 ++++- includes/class-abilities.php | 16 --- 4 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 ABILITIES-API.md diff --git a/ABILITIES-API.md b/ABILITIES-API.md new file mode 100644 index 000000000..0b7978bad --- /dev/null +++ b/ABILITIES-API.md @@ -0,0 +1,163 @@ +# ActivityPub Abilities API + +WordPress Abilities API (WP 6.9+) integration for the ActivityPub plugin. Exposes ActivityPub functionality as discoverable, permissioned abilities for automation, workflows, and plugin interoperability. + +## Why? + +- **Plugin interoperability**: Other plugins can discover and call abilities in a consistent way +- **Automation & workflows**: WP-Cron, WP-CLI, CI/CD, low-code tools can call abilities safely +- **Self-documenting endpoints**: JSON Schema validation, easier tooling/testing +- **REST exposure**: Standard `/wp-json/wp-abilities/v1/` endpoints + +## Categories + +| Slug | Label | Description | +|------|-------|-------------| +| `activitypub-discovery` | Discovery | Look up and discover remote actors in the Fediverse. | +| `activitypub-social` | Social | Manage followers, following, and social connections. | + +## Implemented Abilities + +### Discovery (`activitypub-discovery`) + +| Ability | Description | Class | +|---------|-------------|-------| +| `activitypub/resolve-handle` | Look up WebFinger data for a handle. | `Ability\Webfinger` | +| `activitypub/get-actor` | Fetch profile information for a remote actor. | `Ability\Actor` | + +### Social (`activitypub-social`) + +| Ability | Description | Class | +|---------|-------------|-------| +| `activitypub/get-followers` | List followers for a local actor. | `Ability\Followers` | +| `activitypub/get-following` | List accounts being followed by a local actor. | `Ability\Following` | +| `activitypub/follow` | Follow a remote actor. | `Ability\Following` | +| `activitypub/unfollow` | Unfollow a remote actor. | `Ability\Following` | + +### Planned + +| Ability | Category | Description | +|---------|----------|-------------| +| `activitypub/publish-note` | publish | Publish a Note to the Fediverse. | +| `activitypub/announce-post` | publish | Boost/announce an existing post. | +| `activitypub/block-actor` | moderation | Block a specific actor. | +| `activitypub/block-domain` | moderation | Block an entire domain. | +| `activitypub/unblock` | moderation | Unblock actor or domain. | +| `activitypub/get-inbox` | moderation | Get recent inbox activities. | +| `activitypub/retry-delivery` | moderation | Retry failed activity delivery. | + +## Architecture + +### File Structure + +``` +activitypub.php # Hooks into wp_abilities_api_*_init +includes/ +├── class-abilities.php # Category registration + orchestration + extensibility hooks +├── ability/ +│ ├── class-actor.php # activitypub/get-actor +│ ├── class-followers.php # activitypub/get-followers +│ ├── class-following.php # activitypub/get-following, follow, unfollow +│ └── class-webfinger.php # activitypub/resolve-handle +``` + +### Registration Flow + +1. `activitypub.php` hooks `Abilities::register_categories` into `wp_abilities_api_categories_init` +2. `activitypub.php` hooks `Abilities::register_abilities` into `wp_abilities_api_init` +3. `Abilities::register_categories()` registers categories, then fires `activitypub_register_ability_categories` +4. `Abilities::register_abilities()` calls each ability class's `register()`, then fires `activitypub_register_abilities` + +Ability classes are grouped by domain (e.g. `Webfinger`, `Actor`), not one-per-ability. A single class can register abilities across multiple categories. + +### Ability Class Template + +```php + \__( 'Example Ability', 'activitypub' ), + 'description' => \__( 'Does something useful.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'execute' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param' => array( + 'type' => 'string', + 'description' => \__( 'A required parameter.', 'activitypub' ), + ), + ), + 'required' => array( 'param' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + public static function execute( $input ) { + // Implementation here. + return array( 'result' => 'value' ); + } +} +``` + +### Extensibility + +Third-party plugins can register additional categories and abilities: + +```php +\add_action( 'activitypub_register_ability_categories', function () { + \wp_register_ability_category( 'my-plugin-category', array( ... ) ); +} ); + +\add_action( 'activitypub_register_abilities', function () { + \wp_register_ability( 'my-plugin/my-ability', array( ... ) ); +} ); +``` + +## Existing Services + +- `Webfinger::get_data()` — WebFinger lookups +- `Collection\Remote_Actors::fetch_by_various()`, `::get_actor()` — Remote actor profiles +- `Collection\Followers::query()` — Follower lists +- `Collection\Following::query()` — Following lists +- `Collection\Outbox::reschedule()` — Retry delivery +- `Moderation` class — Block/unblock logic + +## Testing + +```bash +# List categories +curl -u admin:PASSWORD http://localhost:8888/wp-json/wp-abilities/v1/categories + +# List discovery abilities +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities?category=activitypub-discovery' + +# Resolve a handle (GET — readonly abilities require GET) +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/resolve-handle/run?input[handle]=user@example.com' + +# Get actor +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/get-actor/run?input[actor]=user@example.com' +``` diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php index 85c6fb65e..7f5ef9624 100644 --- a/includes/ability/class-actor.php +++ b/includes/ability/class-actor.php @@ -26,9 +26,9 @@ class Actor { */ public static function register() { \wp_register_ability( - 'activitypub/get-actor-info', + 'activitypub/get-actor', array( - 'label' => \__( 'Get Actor Info', 'activitypub' ), + 'label' => \__( 'Get Actor', 'activitypub' ), 'description' => \__( 'Fetch profile information for a remote ActivityPub actor.', 'activitypub' ), 'category' => 'activitypub-discovery', 'execute_callback' => array( self::class, 'get_actor_info' ), diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php index 142070716..dca9cb11e 100644 --- a/includes/ability/class-following.php +++ b/includes/ability/class-following.php @@ -246,6 +246,14 @@ public static function get_following( $input ) { return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); } + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to view another user\'s following list.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; @@ -283,10 +291,18 @@ public static function get_following( $input ) { * @return array|\WP_Error */ public static function follow( $input ) { + if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) { + return new \WP_Error( + 'activitypub_following_disabled', + \__( 'Following feature is disabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $actor = \sanitize_text_field( $input['actor'] ); $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); - if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { return new \WP_Error( 'activitypub_forbidden', \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), @@ -315,10 +331,18 @@ public static function follow( $input ) { * @return array|\WP_Error */ public static function unfollow( $input ) { + if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) { + return new \WP_Error( + 'activitypub_following_disabled', + \__( 'Following feature is disabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $actor = \sanitize_text_field( $input['actor'] ); $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); - if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { return new \WP_Error( 'activitypub_forbidden', \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), diff --git a/includes/class-abilities.php b/includes/class-abilities.php index 267f6680b..3246f38d2 100644 --- a/includes/class-abilities.php +++ b/includes/class-abilities.php @@ -47,22 +47,6 @@ public static function register_categories() { ) ); - \wp_register_ability_category( - 'activitypub-publish', - array( - 'label' => \__( 'Publish', 'activitypub' ), - 'description' => \__( 'Publish and share content to the Fediverse.', 'activitypub' ), - ) - ); - - \wp_register_ability_category( - 'activitypub-moderation', - array( - 'label' => \__( 'Moderation', 'activitypub' ), - 'description' => \__( 'Moderate actors, domains, and activity delivery.', 'activitypub' ), - ) - ); - /** * Fires after built-in ability categories are registered. *