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/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..7f5ef9624 --- /dev/null +++ b/includes/ability/class-actor.php @@ -0,0 +1,144 @@ + \__( 'Get Actor', '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 + * + * @return bool + */ + public static function permission_callback() { + 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..663aa393a --- /dev/null +++ b/includes/ability/class-followers.php @@ -0,0 +1,161 @@ + \__( '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 + * + * @return bool + */ + public static function permission_callback() { + 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..dca9cb11e --- /dev/null +++ b/includes/ability/class-following.php @@ -0,0 +1,363 @@ + \__( '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 + * + * @return bool + */ + public static function permission_callback() { + 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 ) ); + } + + 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; + + $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 ) { + 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( 'manage_options' ) ) { + 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 ) { + 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( 'manage_options' ) ) { + 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..f6e2ae60c --- /dev/null +++ b/includes/ability/class-webfinger.php @@ -0,0 +1,86 @@ + \__( '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 + * + * @return bool + */ + public static function permission_callback() { + 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..3246f38d2 --- /dev/null +++ b/includes/class-abilities.php @@ -0,0 +1,82 @@ + \__( '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' ), + ) + ); + + /** + * 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' ); + } +}