diff --git a/.github/changelog/add-abilities-api b/.github/changelog/add-abilities-api new file mode 100644 index 0000000000..de747cd98b --- /dev/null +++ b/.github/changelog/add-abilities-api @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a WordPress Abilities API integration (WordPress 6.9+) so other plugins can discover and use ActivityPub features such as resolving handles, fetching actors, and managing followers through a stable API. diff --git a/activitypub.php b/activitypub.php index 100f29ffc4..60af387eea 100644 --- a/activitypub.php +++ b/activitypub.php @@ -123,6 +123,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/docs/abilities-api.md b/docs/abilities-api.md new file mode 100644 index 0000000000..4440c99451 --- /dev/null +++ b/docs/abilities-api.md @@ -0,0 +1,262 @@ +# ActivityPub Abilities API + +The ActivityPub plugin exposes its functionality through the [WordPress Abilities API](https://make.wordpress.org/core/) (WordPress 6.9+). Abilities are discoverable, schema-validated, permissioned operations that other plugins, automation tools, WP-CLI, and the REST API can call by name — without depending on the plugin's internal classes. + +## Contents + +- [Why use abilities](#why-use-abilities) +- [Quick start](#quick-start) +- [Authorization](#authorization) +- [Categories](#categories) +- [Ability reference](#ability-reference) + - [Discovery](#discovery) + - [Social](#social) +- [REST API access](#rest-api-access) +- [Architecture](#architecture) +- [Extending the API](#extending-the-api) +- [Roadmap](#roadmap) + +## Why use abilities + +- **Stable contract.** Call `activitypub/get-followers` by name; the plugin can refactor `Collection\Followers` freely without breaking you. +- **Discoverable.** Abilities advertise their input/output JSON Schema, category, and annotations, so tooling can introspect them. +- **Permissioned.** Every ability runs through a permission callback before executing. +- **Multiple surfaces.** The same ability is reachable from PHP (`wp_run_ability()`), the REST API (`/wp-json/wp-abilities/v1/`), and any future Abilities-aware client. + +## Quick start + +Check availability before calling — the API only exists on WordPress 6.9+ and when the plugin is active: + +```php +if ( function_exists( 'wp_get_ability' ) && wp_get_ability( 'activitypub/get-followers' ) ) { + // The ability is registered and can be run. +} +``` + +Run an ability and handle the `WP_Error` result: + +```php +$result = wp_run_ability( + 'activitypub/get-followers', + array( + 'user_id' => get_current_user_id(), + 'per_page' => 20, + 'page' => 1, + ) +); + +if ( ! is_wp_error( $result ) ) { + foreach ( $result['followers'] as $follower ) { + printf( "%s (%s)\n", $follower['name'], $follower['preferredUsername'] ); + } + printf( "Total: %d\n", $result['total'] ); +} +``` + +## Authorization + +Every ability requires the current user to have the `activitypub` capability (`current_user_can( 'activitypub' )`). Some abilities apply additional rules: + +| Rule | Applies to | Behavior | +|------|-----------|----------| +| **Ownership** | `get-followers`, `get-following`, `follow`, `unfollow` | Acting on another user's data requires `manage_options`, otherwise returns `403 activitypub_forbidden`. `user_id` defaults to the current user. | +| **Feature flag** | `follow`, `unfollow` | Requires the Following UI to be enabled (`activitypub_following_ui` option). Otherwise returns `403 activitypub_following_disabled`. | + +Each ability also declares behavioral annotations (`readonly`, `destructive`, `idempotent`) so clients can reason about side effects before running them. + +## 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. | + +## Ability reference + +Every input is an object; `additionalProperties` is disallowed. All abilities return either the documented array or a `WP_Error`. + +### Discovery + +#### `activitypub/resolve-handle` + +Resolve a WebFinger handle to its ActivityPub data. *Readonly · idempotent.* + +| Input | Type | Required | Notes | +|-------|------|----------|-------| +| `handle` | string | yes | WebFinger handle, e.g. `user@example.com`. | + +**Returns:** the WebFinger document (object). **Errors:** `WP_Error` if the handle cannot be resolved. + +```php +$data = wp_run_ability( 'activitypub/resolve-handle', array( 'handle' => 'alice@example.com' ) ); +``` + +#### `activitypub/get-actor` + +Fetch profile information for a remote actor. *Readonly · idempotent.* + +| Input | Type | Required | Notes | +|-------|------|----------|-------| +| `actor` | string | yes | Actor URL or WebFinger handle. | + +**Returns:** + +```jsonc +{ + "id": "https://example.com/users/alice", // uri + "type": "Person", + "name": "Alice", + "preferredUsername": "alice", + "summary": "…", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "followers": "https://example.com/users/alice/followers", + "following": "https://example.com/users/alice/following", + "icon": { /* … */ } +} +``` + +**Errors:** `WP_Error` if the actor cannot be fetched or parsed. + +### Social + +#### `activitypub/get-followers` + +List followers for a local actor. *Readonly · idempotent.* + +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `user_id` | integer | yes | — | The local actor's user ID. | +| `page` | integer | no | `1` | Page number. | +| `per_page` | integer | no | `20` | Results per page (capped at `100`). | + +**Returns:** `{ "followers": Actor[], "total": integer }`, where each follower has `id`, `type`, `name`, `preferredUsername`, `followers`, `following`, `icon`. **Errors:** `400 activitypub_invalid_user_id`, `403 activitypub_forbidden` (another user's list without `manage_options`). + +#### `activitypub/get-following` + +List accounts a local actor follows. *Readonly · idempotent.* + +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `user_id` | integer | yes | — | The local actor's user ID. | +| `page` | integer | no | `1` | Page number. | +| `per_page` | integer | no | `20` | Results per page (capped at `100`). | + +**Returns:** `{ "following": Actor[], "total": integer }` (same `Actor` shape as above). **Errors:** `400 activitypub_invalid_user_id`, `403 activitypub_forbidden` (another user's list without `manage_options`). + +#### `activitypub/follow` + +Follow a remote actor. *Not readonly · not idempotent.* Requires the Following feature to be enabled. + +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `actor` | string | yes | — | Actor URL or WebFinger handle to follow. | +| `user_id` | integer | no | current user | Local actor to act as (ownership rules apply). | + +**Returns:** `{ "outbox_item_id": integer, "status": "pending" }`. **Errors:** `403 activitypub_following_disabled`, `403 activitypub_forbidden`, or a `WP_Error` from the follow request. + +```php +$result = wp_run_ability( 'activitypub/follow', array( 'actor' => 'organizer@events.example.com' ) ); +``` + +#### `activitypub/unfollow` + +Unfollow a remote actor. *Not readonly · not idempotent.* Requires the Following feature to be enabled. + +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `actor` | string | yes | — | Actor URL or WebFinger handle to unfollow. | +| `user_id` | integer | no | current user | Local actor to act as (ownership rules apply). | + +**Returns:** `{ "success": true }`. **Errors:** `403 activitypub_following_disabled`, `403 activitypub_forbidden`, or a `WP_Error` from the unfollow request. + +## REST API access + +Abilities with `show_in_rest` are reachable under `/wp-json/wp-abilities/v1/`. Readonly abilities run via `GET`; others via `POST`. + +```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' + +# Run a readonly ability (GET). +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/get-actor/run?input[actor]=alice@example.com' + +# Run a write ability (POST). +curl -u admin:PASSWORD -X POST -H 'Content-Type: application/json' \ + -d '{"input":{"actor":"alice@example.com"}}' \ + http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/follow/run +``` + +## Architecture + +``` +activitypub.php # Hooks Abilities::register_* 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` and `Abilities::register_abilities()` into `wp_abilities_api_init`. These hooks only fire when the Abilities API is available, so no version check is needed. +2. `register_categories()` registers the categories, then fires `activitypub_register_ability_categories`. +3. `register_abilities()` calls each ability class's `register()`, then fires `activitypub_register_abilities`. + +Ability classes are grouped by domain (`Webfinger`, `Actor`, `Followers`, `Following`), not one class per ability — a single class can register several abilities, even across categories. + +## Extending the API + +Third-party plugins register their own categories and abilities on the same hooks: + +```php +add_action( 'activitypub_register_ability_categories', function () { + wp_register_ability_category( 'my-plugin', array( + 'label' => __( 'My Plugin', 'my-plugin' ), + 'description' => __( 'Custom abilities.', 'my-plugin' ), + ) ); +} ); + +add_action( 'activitypub_register_abilities', function () { + wp_register_ability( 'my-plugin/do-thing', array( + 'label' => __( 'Do Thing', 'my-plugin' ), + 'description' => __( 'Does the thing.', 'my-plugin' ), + 'category' => 'my-plugin', + 'execute_callback' => 'my_plugin_do_thing', + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param' => array( 'type' => 'string' ), + ), + 'required' => array( 'param' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( 'type' => 'object' ), + 'meta' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), + 'show_in_rest' => true, + ), + ) ); +} ); +``` + +## Roadmap + +Abilities under consideration for future releases: + +| 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 an actor or domain. | +| `activitypub/get-inbox` | Moderation | Get recent inbox activities. | +| `activitypub/retry-delivery` | Moderation | Retry failed activity delivery. | diff --git a/docs/developer-docs.md b/docs/developer-docs.md index 6db44bc87f..61f0ffb6cb 100644 --- a/docs/developer-docs.md +++ b/docs/developer-docs.md @@ -4,6 +4,7 @@ - [Introduction](#introduction) - [Snippets](#snippets) - [Extending the Settings Interface](#extending-the-settings-interface) +- [Abilities API](#abilities-api) ## Introduction This documentation provides information for developers who want to extend and build upon the ActivityPub plugin. Whether you're developing a complementary plugin or integrating ActivityPub features into your existing WordPress plugin, this guide will help you understand the available hooks and customization options. @@ -77,3 +78,9 @@ add_action( 'admin_enqueue_scripts', function( $hook ) { } } ); ``` + +## Abilities API + +The plugin registers its operations as WordPress Abilities (WP 6.9+), giving other plugins a stable, discoverable way to interact with ActivityPub without depending on internal classes. + +See [Abilities API](./abilities-api.md) for the available abilities, their input and output schemas, and usage examples. diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php new file mode 100644 index 0000000000..143362a89e --- /dev/null +++ b/includes/ability/class-actor.php @@ -0,0 +1,204 @@ + \__( '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_merge( + self::to_array( $actor ), + array( + 'summary' => $actor->get_summary(), + 'inbox' => $actor->get_inbox(), + 'outbox' => $actor->get_outbox(), + ) + ); + } + + /** + * Map a remote actor to the common ability response shape. + * + * Shared by the actor, followers, and following abilities so they all + * expose the same actor fields. + * + * @since unreleased + * + * @param \Activitypub\Activity\Actor $actor The actor object. + * @return array + */ + public static function to_array( $actor ) { + return 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(), + ); + } + + /** + * JSON Schema for a single actor item in ability output. + * + * Shared by abilities that return lists of actors. + * + * @since unreleased + * + * @return array + */ + public static function item_schema() { + return 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', + ), + ), + ); + } +} diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php new file mode 100644 index 0000000000..9f30f07b06 --- /dev/null +++ b/includes/ability/class-followers.php @@ -0,0 +1,133 @@ + \__( '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' => Actor::item_schema(), + ), + '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 ) ); + } + + 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 followers list.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; + $page = isset( $input['page'] ) ? \max( 1, \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[] = Actor::to_array( $actor ); + } + + 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 0000000000..c3175067d7 --- /dev/null +++ b/includes/ability/class-following.php @@ -0,0 +1,327 @@ + \__( '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' => Actor::item_schema(), + ), + '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'] ) ? \max( 1, \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[] = Actor::to_array( $actor ); + } + + 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 0000000000..f6e2ae60c8 --- /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 0000000000..3246f38d2d --- /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' ); + } +} diff --git a/tests/phpunit/tests/ability/class-test-actor.php b/tests/phpunit/tests/ability/class-test-actor.php new file mode 100644 index 0000000000..1d8ce1d64c --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-actor.php @@ -0,0 +1,79 @@ +assertFalse( Actor::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Actor::permission_callback() ); + } + + /** + * The shared item schema exposes the common actor properties. + * + * @covers ::item_schema + */ + public function test_item_schema_properties() { + $schema = Actor::item_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertSame( + array( 'id', 'type', 'name', 'preferredUsername', 'followers', 'following', 'icon' ), + \array_keys( $schema['properties'] ) + ); + } + + /** + * Maps the shared actor fields and omits detail-only fields. + * + * @covers ::to_array + */ + public function test_to_array_maps_common_fields() { + $model = new \Activitypub\Activity\Actor(); + $model->set_id( 'https://example.com/users/alice' ); + $model->set_type( 'Person' ); + $model->set_name( 'Alice' ); + $model->set_preferred_username( 'alice' ); + $model->set_followers( 'https://example.com/users/alice/followers' ); + $model->set_following( 'https://example.com/users/alice/following' ); + $model->set_icon( array( 'type' => 'Image' ) ); + + $array = Actor::to_array( $model ); + + $this->assertSame( 'https://example.com/users/alice', $array['id'] ); + $this->assertSame( 'Person', $array['type'] ); + $this->assertSame( 'Alice', $array['name'] ); + $this->assertSame( 'alice', $array['preferredUsername'] ); + $this->assertSame( 'https://example.com/users/alice/followers', $array['followers'] ); + $this->assertSame( 'https://example.com/users/alice/following', $array['following'] ); + $this->assertSame( array( 'type' => 'Image' ), $array['icon'] ); + + // Detail-only fields belong to get-actor, not the shared shape. + $this->assertArrayNotHasKey( 'summary', $array ); + $this->assertArrayNotHasKey( 'inbox', $array ); + $this->assertArrayNotHasKey( 'outbox', $array ); + } +} diff --git a/tests/phpunit/tests/ability/class-test-followers.php b/tests/phpunit/tests/ability/class-test-followers.php new file mode 100644 index 0000000000..27fb18daa5 --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-followers.php @@ -0,0 +1,78 @@ +assertFalse( Followers::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Followers::permission_callback() ); + } + + /** + * An invalid user ID is rejected. + * + * @covers ::get_followers + */ + public function test_get_followers_rejects_invalid_user_id() { + $result = Followers::get_followers( array( 'user_id' => 0 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_invalid_user_id', $result->get_error_code() ); + } + + /** + * A non-admin cannot read another user's followers list. + * + * @covers ::get_followers + */ + public function test_get_followers_forbids_other_user() { + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Followers::get_followers( array( 'user_id' => $other ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } + + /** + * An administrator can read another user's followers list. + * + * @covers ::get_followers + */ + public function test_get_followers_allows_admin_for_other_user() { + $admin = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $other = self::factory()->user->create(); + \wp_set_current_user( $admin ); + + $result = Followers::get_followers( array( 'user_id' => $other ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'followers', $result ); + $this->assertArrayHasKey( 'total', $result ); + } +} diff --git a/tests/phpunit/tests/ability/class-test-following.php b/tests/phpunit/tests/ability/class-test-following.php new file mode 100644 index 0000000000..725fe1fa8a --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-following.php @@ -0,0 +1,142 @@ +assertFalse( Following::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Following::permission_callback() ); + } + + /** + * An invalid user ID is rejected. + * + * @covers ::get_following + */ + public function test_get_following_rejects_invalid_user_id() { + $result = Following::get_following( array( 'user_id' => 0 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_invalid_user_id', $result->get_error_code() ); + } + + /** + * A non-admin cannot read another user's following list. + * + * @covers ::get_following + */ + public function test_get_following_forbids_other_user() { + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Following::get_following( array( 'user_id' => $other ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } + + /** + * A user can read their own following list. + * + * @covers ::get_following + */ + public function test_get_following_allows_own_user() { + $user = self::factory()->user->create(); + \wp_set_current_user( $user ); + + $result = Following::get_following( array( 'user_id' => $user ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'following', $result ); + $this->assertArrayHasKey( 'total', $result ); + } + + /** + * Follow is rejected when the following feature is disabled. + * + * @covers ::follow + */ + public function test_follow_requires_following_feature() { + \update_option( 'activitypub_following_ui', '0' ); + + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + \wp_set_current_user( $user ); + + $result = Following::follow( array( 'actor' => 'alice@example.com' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_following_disabled', $result->get_error_code() ); + } + + /** + * Unfollow is rejected when the following feature is disabled. + * + * @covers ::unfollow + */ + public function test_unfollow_requires_following_feature() { + \update_option( 'activitypub_following_ui', '0' ); + + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + \wp_set_current_user( $user ); + + $result = Following::unfollow( array( 'actor' => 'alice@example.com' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_following_disabled', $result->get_error_code() ); + } + + /** + * A non-admin cannot follow on behalf of another user. + * + * @covers ::follow + */ + public function test_follow_forbids_acting_for_other_user() { + \update_option( 'activitypub_following_ui', '1' ); + + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Following::follow( + array( + 'actor' => 'alice@example.com', + 'user_id' => $other, + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } +}