updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -0,0 +1,253 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Actions REST Controller
|
||||
*
|
||||
* Handles administrative actions for followers/actors management.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest\Admin;
|
||||
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Moderation;
|
||||
|
||||
use function Activitypub\user_can_activitypub;
|
||||
|
||||
/**
|
||||
* Admin Actions REST Controller Class.
|
||||
*/
|
||||
class Actions_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'admin/actors';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
// Delete follower relationship.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/(?P<id>[\d]+)/unfollow',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_actor_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::DELETABLE,
|
||||
'callback' => array( $this, 'unfollow_actor' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
'show_in_index' => false,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Block actor.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/(?P<id>[\d]+)/block',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_actor_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'block_actor' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
'show_in_index' => false,
|
||||
'args' => array(
|
||||
'site_wide' => array(
|
||||
'description' => 'Whether to block site-wide (admin only).',
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Follow actor.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/(?P<id>[\d]+)/follow',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_actor_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'follow_actor' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
'show_in_index' => false,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has permission to perform actions.
|
||||
*
|
||||
* @return bool|\WP_Error True if the request has permission, WP_Error object otherwise.
|
||||
*/
|
||||
public function check_permission() {
|
||||
if ( ! user_can_activitypub( \get_current_user_id() ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_forbidden',
|
||||
\__( 'Sorry, you are not allowed to perform this action.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate actor ID.
|
||||
*
|
||||
* @param int $value The actor ID.
|
||||
* @return bool True if valid, false otherwise.
|
||||
*/
|
||||
public function validate_actor_id( $value ) {
|
||||
$actor = \get_post( $value );
|
||||
|
||||
return $actor instanceof \WP_Post && Remote_Actors::POST_TYPE === $actor->post_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove follower relationship.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full data about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function unfollow_actor( $request ) {
|
||||
$actor_id = $request->get_param( 'id' );
|
||||
$user_id = \get_current_user_id();
|
||||
|
||||
$result = Followers::remove( $actor_id, $user_id );
|
||||
|
||||
if ( ! $result ) {
|
||||
return new \WP_Error(
|
||||
'rest_follower_removal_failed',
|
||||
\__( 'Failed to remove follower.', 'activitypub' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'message' => \__( 'Follower removed successfully.', 'activitypub' ),
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an actor.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full data about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function block_actor( $request ) {
|
||||
$actor_id = $request->get_param( 'id' );
|
||||
$site_wide = $request->get_param( 'site_wide' );
|
||||
$user_id = \get_current_user_id();
|
||||
|
||||
$actor = Remote_Actors::get_actor( $actor_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
$actor_url = $actor->get_id();
|
||||
|
||||
// Add user-specific block.
|
||||
$user_block_success = Moderation::add_user_block( $user_id, 'actor', $actor_url );
|
||||
|
||||
// Add site-wide block if requested and user has permission.
|
||||
$site_block_success = true;
|
||||
if ( $site_wide && \current_user_can( 'manage_options' ) ) {
|
||||
$site_block_success = Moderation::add_site_block( 'actor', $actor_url );
|
||||
}
|
||||
|
||||
if ( ! $user_block_success || ! $site_block_success ) {
|
||||
return new \WP_Error(
|
||||
'rest_actor_block_failed',
|
||||
\__( 'Failed to block actor.', 'activitypub' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Remove follower relationship after blocking.
|
||||
Followers::remove( $actor_id, $user_id );
|
||||
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'message' => \__( 'Actor blocked successfully.', 'activitypub' ),
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow an actor.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full data about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function follow_actor( $request ) {
|
||||
// Check if following UI is enabled.
|
||||
if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_following_disabled',
|
||||
\__( 'Following feature is disabled.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$actor_id = $request->get_param( 'id' );
|
||||
$user_id = \get_current_user_id();
|
||||
|
||||
$result = Following::follow( $actor_id, $user_id );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'message' => \__( 'Actor followed successfully.', 'activitypub' ),
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/**
|
||||
* Statistics_Controller file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest\Admin;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Statistics;
|
||||
|
||||
use function Activitypub\user_can_act_as_blog;
|
||||
|
||||
/**
|
||||
* ActivityPub Statistics_Controller class.
|
||||
*
|
||||
* Provides REST endpoints for ActivityPub statistics.
|
||||
*/
|
||||
class Statistics_Controller extends \WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'admin/stats';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/(?P<user_id>[\d]+)',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => array( $this, 'get_item_permissions_check' ),
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The user ID to get stats for.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given request has access to get stats.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return true|\WP_Error True if the request has access, WP_Error otherwise.
|
||||
*/
|
||||
public function get_item_permissions_check( $request ) {
|
||||
$user_id = (int) $request->get_param( 'user_id' );
|
||||
|
||||
// Check if user can access stats for this actor.
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
if ( ! user_can_act_as_blog() ) {
|
||||
return new \WP_Error(
|
||||
'rest_forbidden',
|
||||
\__( 'You do not have permission to view blog stats.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
} elseif ( \get_current_user_id() !== $user_id ) {
|
||||
return new \WP_Error(
|
||||
'rest_forbidden',
|
||||
\__( 'You do not have permission to view this user\'s stats.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves statistics for a user.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error Response object or WP_Error object.
|
||||
*/
|
||||
public function get_item( $request ) {
|
||||
$user_id = (int) $request->get_param( 'user_id' );
|
||||
$transient_key = 'activitypub_stats_' . $user_id;
|
||||
|
||||
$response = \get_transient( $transient_key );
|
||||
|
||||
if ( false === $response ) {
|
||||
$stats = Statistics::get_current_stats( $user_id, 'month' );
|
||||
$comparison = Statistics::get_period_comparison( $user_id, $stats );
|
||||
$monthly_data = Statistics::get_rolling_monthly_breakdown( $user_id );
|
||||
$comment_types = Statistics::get_comment_types_for_stats();
|
||||
|
||||
$stats_response = array(
|
||||
'posts_count' => $stats['posts_count'],
|
||||
'followers_total' => $stats['followers_total'],
|
||||
'top_posts' => $stats['top_posts'],
|
||||
'top_multiplicator' => $stats['top_multiplicator'],
|
||||
);
|
||||
|
||||
// Include per-type engagement counts from current period stats.
|
||||
foreach ( \array_keys( $comment_types ) as $type ) {
|
||||
$stats_response[ $type . '_count' ] = $stats[ $type . '_count' ] ?? 0;
|
||||
}
|
||||
|
||||
$response = array(
|
||||
'stats' => $stats_response,
|
||||
'comparison' => $comparison,
|
||||
'monthly' => \array_values( $monthly_data ),
|
||||
'comment_types' => $comment_types,
|
||||
);
|
||||
|
||||
\set_transient( $transient_key, $response, 15 * MINUTE_IN_SECONDS );
|
||||
}
|
||||
|
||||
return \rest_ensure_response( $response );
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,6 @@ namespace Activitypub\Rest;
|
||||
use Activitypub\Collection\Actors as Actor_Collection;
|
||||
use Activitypub\Webfinger;
|
||||
|
||||
use function Activitypub\is_activitypub_request;
|
||||
|
||||
/**
|
||||
* ActivityPub Actors REST-Class.
|
||||
*
|
||||
@ -20,6 +18,8 @@ use function Activitypub\is_activitypub_request;
|
||||
* @see https://www.w3.org/TR/activitypub/#followers
|
||||
*/
|
||||
class Actors_Controller extends \WP_REST_Controller {
|
||||
use Verification;
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
@ -32,7 +32,7 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = '(?:users|actors)\/(?P<user_id>[\w\-\.]+)';
|
||||
protected $rest_base = '(?:users|actors)\/(?P<user_id>[-]?\d+)';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
@ -44,16 +44,16 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID or username of the actor.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '[\w\-\.]+',
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
),
|
||||
'schema' => array( $this, 'get_public_item_schema' ),
|
||||
)
|
||||
@ -65,10 +65,10 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID or username of the actor.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '[\w\-\.]+',
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
@ -95,11 +95,7 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
*/
|
||||
public function get_item( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actor_Collection::get_by_various( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
$user = Actor_Collection::get_by_id( $user_id );
|
||||
|
||||
/**
|
||||
* Action triggered prior to the ActivityPub profile being created and sent to the client.
|
||||
@ -124,11 +120,7 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
public function get_remote_follow_item( $request ) {
|
||||
$resource = $request->get_param( 'resource' );
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actor_Collection::get_by_various( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
$user = Actor_Collection::get_by_id( $user_id );
|
||||
|
||||
$template = Webfinger::get_remote_follow_endpoint( $resource );
|
||||
|
||||
@ -351,9 +343,49 @@ class Actors_Controller extends \WP_REST_Controller {
|
||||
'type' => 'boolean',
|
||||
'readonly' => true,
|
||||
),
|
||||
'generator' => array(
|
||||
'description' => 'The generator of the object.',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'type' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'implements' => array(
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'href' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'name' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
'readonly' => true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the user_id parameter.
|
||||
*
|
||||
* @param mixed $user_id The user_id parameter.
|
||||
* @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise.
|
||||
*/
|
||||
public function validate_user_id( $user_id ) {
|
||||
$user = Actor_Collection::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,12 +8,15 @@
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Debug;
|
||||
use Activitypub\Collection\Inbox;
|
||||
use Activitypub\Moderation;
|
||||
|
||||
use function Activitypub\get_context;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\camel_to_snake_case;
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\object_to_uri;
|
||||
|
||||
/**
|
||||
* Actors_Inbox_Controller class.
|
||||
@ -24,6 +27,8 @@ use function Activitypub\get_masked_wp_version;
|
||||
*/
|
||||
class Actors_Inbox_Controller extends Actors_Controller {
|
||||
use Collection;
|
||||
use Event_Stream;
|
||||
use Language_Map;
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
@ -35,16 +40,16 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID or username of the actor.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '[\w\-\.]+',
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'permission_callback' => array( $this, 'verify_authentication' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
@ -57,6 +62,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
'type' => 'integer',
|
||||
'default' => 20,
|
||||
'minimum' => 1,
|
||||
'maximum' => 100,
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_collection_schema' ),
|
||||
@ -64,7 +70,7 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'create_item' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The unique identifier for the activity.',
|
||||
@ -79,21 +85,27 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
'sanitize_callback' => '\Activitypub\object_to_uri',
|
||||
),
|
||||
'type' => array(
|
||||
'description' => 'The type of the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => 'The type of the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_html_class',
|
||||
'validate_callback' => static function ( $param ) {
|
||||
// Reject values that sanitize to empty so dynamic hook names always have a suffix.
|
||||
return '' !== \sanitize_html_class( (string) $param );
|
||||
},
|
||||
),
|
||||
'object' => array(
|
||||
'description' => 'The object of the activity.',
|
||||
'required' => true,
|
||||
'validate_callback' => function ( $param, $request, $key ) {
|
||||
'sanitize_callback' => array( $this, 'localize_language_maps' ),
|
||||
'validate_callback' => static function ( $param, $request, $key ) {
|
||||
/**
|
||||
* Filter the ActivityPub object validation.
|
||||
*
|
||||
* @param bool $validate The validation result.
|
||||
* @param array $param The object data.
|
||||
* @param object $request The request object.
|
||||
* @param string $key The key.
|
||||
* @param bool $validate The validation result.
|
||||
* @param array $param The object data.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param string $key The key.
|
||||
*/
|
||||
return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
|
||||
},
|
||||
@ -103,42 +115,100 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/inbox/stream',
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => function ( $request ) {
|
||||
$this->stream_collection( $request->get_param( 'user_id' ), 'inbox' );
|
||||
},
|
||||
'permission_callback' => array( $this, 'get_stream_permissions_check' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_create_item' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the user-inbox.
|
||||
* Retrieves a collection of inbox items.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response|\WP_Error Response object or WP_Error.
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$page = $request->get_param( 'page' ) ?? 1;
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires before the ActivityPub inbox is created and sent to the client.
|
||||
* Action triggered prior to the ActivityPub inbox being created and sent to the client.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
\do_action( 'activitypub_rest_inbox_pre' );
|
||||
\do_action( 'activitypub_rest_inbox_pre', $request );
|
||||
|
||||
$response = array(
|
||||
'@context' => get_context(),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user->get__id() ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => 0,
|
||||
'orderedItems' => array(),
|
||||
$args = array(
|
||||
'posts_per_page' => $request->get_param( 'per_page' ),
|
||||
'paged' => $page,
|
||||
'post_type' => Inbox::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_activitypub_user_id',
|
||||
'value' => $user_id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters the ActivityPub inbox data before it is sent to the client.
|
||||
* Filters WP_Query arguments when querying Inbox items via the REST API.
|
||||
*
|
||||
* @param array $response The ActivityPub inbox array.
|
||||
* Enables adding extra arguments or setting defaults for an inbox collection request.
|
||||
*
|
||||
* @param array $args Array of arguments for WP_Query.
|
||||
* @param \WP_REST_Request $request The REST API request.
|
||||
*/
|
||||
$response = \apply_filters( 'activitypub_rest_inbox_array', $response );
|
||||
$args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request );
|
||||
|
||||
$inbox_query = new \WP_Query();
|
||||
$query_result = $inbox_query->query( $args );
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => $user->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => (int) $inbox_query->found_posts,
|
||||
'eventStream' => $this->get_stream_url( $user_id, 'inbox' ),
|
||||
'orderedItems' => array(),
|
||||
);
|
||||
|
||||
\update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
|
||||
foreach ( $query_result as $inbox_item ) {
|
||||
if ( ! $inbox_item instanceof \WP_Post ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request );
|
||||
}
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
@ -146,9 +216,27 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after the ActivityPub inbox has been created and sent to the client.
|
||||
* Filter the ActivityPub inbox array.
|
||||
*
|
||||
* @param array $response The ActivityPub inbox array.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_post' );
|
||||
$response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request );
|
||||
|
||||
/**
|
||||
* Action triggered after the ActivityPub inbox has been created and sent to the client.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
\do_action( 'activitypub_rest_inbox_post', $request );
|
||||
|
||||
// Fire deprecated hook for backward compatibility.
|
||||
\do_action_deprecated(
|
||||
'activitypub_inbox_post',
|
||||
array( $request ),
|
||||
'8.1.0',
|
||||
'activitypub_rest_inbox_post'
|
||||
);
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
@ -156,6 +244,19 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the item for the REST response.
|
||||
*
|
||||
* @param mixed $item WordPress representation of the item.
|
||||
* @param \WP_REST_Request $request Request object.
|
||||
* @return array Response object on success.
|
||||
*/
|
||||
public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$activity = \json_decode( $item->post_content, true );
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user-inbox requests.
|
||||
*
|
||||
@ -165,20 +266,23 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
*/
|
||||
public function create_item( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
$data = $request->get_json_params();
|
||||
$type = camel_to_snake_case( $request->get_param( 'type' ) );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
/* @var Activity $activity Activity object.*/
|
||||
$activity = Activity::init_from_array( $data );
|
||||
$type = $request->get_param( 'type' );
|
||||
$type = \strtolower( $type );
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
if ( \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', '', $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) {
|
||||
Debug::write_log( 'Blocked activity from: ' . $activity->get_actor() );
|
||||
if ( Moderation::activity_is_blocked( $activity, $user_id ) ) {
|
||||
/**
|
||||
* ActivityPub inbox disallowed activity.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int|null $user_id The user ID.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
*/
|
||||
do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
|
||||
} else {
|
||||
/**
|
||||
* ActivityPub inbox action.
|
||||
@ -187,8 +291,9 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
* @param int|null $user_id The user ID.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request.
|
||||
*/
|
||||
\do_action( 'activitypub_inbox', $data, $user->get__id(), $type, $activity );
|
||||
\do_action( 'activitypub_inbox', $data, $user_id, $type, $activity, Inbox::CONTEXT_INBOX );
|
||||
|
||||
/**
|
||||
* ActivityPub inbox action for specific activity types.
|
||||
@ -196,11 +301,41 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
* @param array $data The data array.
|
||||
* @param int|null $user_id The user ID.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request.
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_' . $type, $data, $user->get__id(), $activity );
|
||||
\do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity, Inbox::CONTEXT_INBOX );
|
||||
|
||||
/**
|
||||
* Filter to skip inbox storage.
|
||||
*
|
||||
* Skip inbox storage for debugging purposes or to reduce load for
|
||||
* certain Activity-Types, like "Delete".
|
||||
*
|
||||
* @param bool $skip Whether to skip inbox storage.
|
||||
* @param array $data The activity data array.
|
||||
*
|
||||
* @return bool Whether to skip inbox storage.
|
||||
*/
|
||||
$skip = \apply_filters( 'activitypub_skip_inbox_storage', false, $data );
|
||||
|
||||
if ( ! $skip ) {
|
||||
$activity_id = object_to_uri( $data );
|
||||
|
||||
Inbox::add( $activity, (array) $user_id );
|
||||
|
||||
\wp_clear_scheduled_hook( 'activitypub_inbox_create_item', array( $activity_id ) );
|
||||
\wp_schedule_single_event( time() + 15, 'activitypub_inbox_create_item', array( $activity_id ) );
|
||||
}
|
||||
}
|
||||
|
||||
$response = \rest_ensure_response( array() );
|
||||
$response = \rest_ensure_response(
|
||||
array(
|
||||
'type' => 'https://w3id.org/fep/c180#approval-required',
|
||||
'title' => 'Approval Required',
|
||||
'status' => '202',
|
||||
'detail' => 'This activity requires approval before it can be processed.',
|
||||
)
|
||||
);
|
||||
$response->set_status( 202 );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
@ -235,4 +370,52 @@ class Actors_Inbox_Controller extends Actors_Controller {
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process cached inbox activity.
|
||||
*
|
||||
* Retrieves all collected user IDs for an activity and processes them together.
|
||||
*
|
||||
* @param string $activity_id The activity ID.
|
||||
*/
|
||||
public static function process_create_item( $activity_id ) {
|
||||
// Deduplicate if multiple inbox items were created due to race condition.
|
||||
$inbox_item = Inbox::deduplicate( $activity_id );
|
||||
if ( ! $inbox_item ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = \json_decode( $inbox_item->post_content, true );
|
||||
// Reconstruct activity from inbox post.
|
||||
$activity = Activity::init_from_array( $data );
|
||||
// Sanitize again here: the type comes from stored activity JSON, which bypassed REST arg sanitization.
|
||||
$type = camel_to_snake_case( \sanitize_html_class( (string) $activity->get_type() ) );
|
||||
$context = Inbox::CONTEXT_INBOX;
|
||||
$user_ids = Inbox::get_recipients( $inbox_item->ID );
|
||||
|
||||
/**
|
||||
* Fires after any ActivityPub Inbox activity has been handled, regardless of activity type.
|
||||
*
|
||||
* This hook is triggered for all activity types processed by the inbox handler.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param array $user_ids The user IDs.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity $activity The Activity object.
|
||||
* @param int $result The ID of the inbox item that was created, or WP_Error if failed.
|
||||
* @param string $context The context of the request ('inbox' or 'shared_inbox').
|
||||
*/
|
||||
\do_action( 'activitypub_handled_inbox', $data, $user_ids, $type, $activity, $inbox_item->ID, $context );
|
||||
|
||||
/**
|
||||
* Fires after an ActivityPub Inbox activity has been handled.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param array $user_ids The user IDs.
|
||||
* @param Activity $activity The Activity object.
|
||||
* @param int $result The ID of the inbox item that was created, or WP_Error if failed.
|
||||
* @param string $context The context of the request ('inbox' or 'shared_inbox').
|
||||
*/
|
||||
\do_action( 'activitypub_handled_inbox_' . $type, $data, $user_ids, $activity, $inbox_item->ID, $context );
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,6 +157,21 @@ class Application_Controller extends \WP_REST_Controller {
|
||||
'indexable' => array(
|
||||
'type' => 'boolean',
|
||||
),
|
||||
'implements' => array(
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'href' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'name' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
'webfinger' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
|
||||
@ -9,14 +9,11 @@ namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Model\Application;
|
||||
use Activitypub\Model\Blog;
|
||||
use Activitypub\Model\User;
|
||||
use Activitypub\Transformer\Factory;
|
||||
|
||||
use function Activitypub\esc_hashtag;
|
||||
use function Activitypub\is_single_user;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\is_single_user;
|
||||
|
||||
/**
|
||||
* Collections_Controller class.
|
||||
@ -40,9 +37,10 @@ class Collections_Controller extends Actors_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The user ID or username.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => 'The user ID or username.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
'type' => array(
|
||||
'description' => 'The type of collection to query.',
|
||||
@ -84,19 +82,14 @@ class Collections_Controller extends Actors_Controller {
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
switch ( $request->get_param( 'type' ) ) {
|
||||
case 'tags':
|
||||
$response = $this->get_tags( $request, $user );
|
||||
$response = $this->get_tags( $request, $user_id );
|
||||
break;
|
||||
|
||||
case 'featured':
|
||||
$response = $this->get_featured( $request, $user );
|
||||
$response = $this->get_featured( $request, $user_id );
|
||||
break;
|
||||
|
||||
default:
|
||||
@ -116,12 +109,12 @@ class Collections_Controller extends Actors_Controller {
|
||||
/**
|
||||
* Retrieves a collection of featured tags.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param User|Blog|Application $user Actor.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param int $user_id Actor ID.
|
||||
*
|
||||
* @return array Collection of featured tags.
|
||||
*/
|
||||
public function get_tags( $request, $user ) {
|
||||
public function get_tags( $request, $user_id ) {
|
||||
$tags = \get_terms(
|
||||
array(
|
||||
'taxonomy' => 'post_tag',
|
||||
@ -137,7 +130,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user->get__id() ) ),
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/tags', $user_id ) ),
|
||||
'type' => 'Collection',
|
||||
'totalItems' => \is_countable( $tags ) ? \count( $tags ) : 0,
|
||||
'items' => array(),
|
||||
@ -157,15 +150,15 @@ class Collections_Controller extends Actors_Controller {
|
||||
/**
|
||||
* Retrieves a collection of featured posts.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param User|Blog|Application $user Actor.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param int $user_id Actor ID.
|
||||
*
|
||||
* @return array Collection of featured posts.
|
||||
*/
|
||||
public function get_featured( $request, $user ) {
|
||||
public function get_featured( $request, $user_id ) {
|
||||
$posts = array();
|
||||
|
||||
if ( is_single_user() || Actors::BLOG_USER_ID !== $user->get__id() ) {
|
||||
if ( is_single_user() || Actors::BLOG_USER_ID !== $user_id ) {
|
||||
$sticky_posts = \get_option( 'sticky_posts' );
|
||||
|
||||
if ( $sticky_posts && is_array( $sticky_posts ) ) {
|
||||
@ -184,8 +177,8 @@ class Collections_Controller extends Actors_Controller {
|
||||
),
|
||||
);
|
||||
|
||||
if ( $user->get__id() > 0 ) {
|
||||
$args['author'] = $user->get__id();
|
||||
if ( $user_id > 0 ) {
|
||||
$args['author'] = $user_id;
|
||||
}
|
||||
|
||||
$posts = \get_posts( $args );
|
||||
@ -194,7 +187,7 @@ class Collections_Controller extends Actors_Controller {
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $request->get_param( 'user_id' ) ) ),
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/collections/featured', $user_id ) ),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => \is_countable( $posts ) ? \count( $posts ) : 0,
|
||||
'orderedItems' => array(),
|
||||
|
||||
@ -10,6 +10,8 @@ namespace Activitypub\Rest;
|
||||
use Activitypub\Comment;
|
||||
use Activitypub\Webfinger;
|
||||
|
||||
use function Activitypub\is_post_publicly_queryable;
|
||||
|
||||
/**
|
||||
* Comments_Controller class.
|
||||
*
|
||||
@ -30,7 +32,7 @@ class Comments_Controller extends \WP_REST_Controller {
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'comments/(?P<comment_id>\d+)';
|
||||
protected $rest_base = 'comments/(?P<comment_id>[\d]+)';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
@ -44,6 +46,7 @@ class Comments_Controller extends \WP_REST_Controller {
|
||||
'comment_id' => array(
|
||||
'description' => 'The ID of the comment.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_comment' ),
|
||||
),
|
||||
@ -79,9 +82,18 @@ class Comments_Controller extends \WP_REST_Controller {
|
||||
return new \WP_Error( 'activitypub_comment_not_found', \__( 'Comment not found', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
$is_local = Comment::is_local( $comment );
|
||||
/*
|
||||
* A comment inherits the visibility of its parent post. Check this
|
||||
* BEFORE the local-only guard below, so that any comment on a non-
|
||||
* publicly-queryable parent returns the same "not found" shape — we
|
||||
* never want an outsider to distinguish "comment exists but is local"
|
||||
* from "comment missing" when the parent post is private.
|
||||
*/
|
||||
if ( ! is_post_publicly_queryable( $comment->comment_post_ID ) ) {
|
||||
return new \WP_Error( 'activitypub_comment_not_found', \__( 'Comment not found', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
if ( $is_local ) {
|
||||
if ( Comment::is_local( $comment ) ) {
|
||||
return new \WP_Error( 'activitypub_local_only_comment', \__( 'Comment is local only', 'activitypub' ), array( 'status' => 403 ) );
|
||||
}
|
||||
|
||||
|
||||
@ -7,12 +7,13 @@
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\get_context;
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\is_unsafe_ipv6_literal;
|
||||
|
||||
/**
|
||||
* Followers_Controller class.
|
||||
@ -34,16 +35,16 @@ class Followers_Controller extends Actors_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID or username of the actor.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '[\w\-\.]+',
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
@ -74,6 +75,99 @@ class Followers_Controller extends Actors_Controller {
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
// FEP-8fcf: Partial followers collection for synchronization.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/followers/sync',
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_partial_followers' ),
|
||||
|
||||
/*
|
||||
* FEP-8fcf requires that the partial followers collection only be
|
||||
* disclosed to an authenticated peer. Force signature verification
|
||||
* even when Authorized Fetch is globally disabled.
|
||||
*/
|
||||
'permission_callback' => function ( $request ) {
|
||||
return $this->verify_signature( $request, true );
|
||||
},
|
||||
'args' => array(
|
||||
'authority' => array(
|
||||
'description' => 'The host to filter followers by.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'pattern' => '^https?://[^/]+$',
|
||||
'required' => true,
|
||||
'validate_callback' => static function ( $param ) {
|
||||
/*
|
||||
* Reject internal-address shapes early. The signer-host check
|
||||
* downstream already enforces authority matching the verified
|
||||
* peer; this just keeps obviously-internal values from reaching
|
||||
* that code at all. Both places run the value through
|
||||
* self::normalize_host() so semantically equivalent hosts always
|
||||
* agree.
|
||||
*
|
||||
* Percent-decode the input first so encoded forms like
|
||||
* `https://%5B::1%5D` (bracketed IPv6 literal hidden inside
|
||||
* %5B/%5D) get checked against the same blocklist as the
|
||||
* unencoded equivalent. Use rawurldecode() rather than
|
||||
* urldecode() — the latter also turns `+` into a space, which
|
||||
* would corrupt otherwise-valid reg-name hosts.
|
||||
*/
|
||||
$decoded = \rawurldecode( (string) $param );
|
||||
$host = self::normalize_host( (string) \wp_parse_url( $decoded, PHP_URL_HOST ) );
|
||||
if ( '' === $host ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( \filter_var( $host, FILTER_VALIDATE_IP ) ) {
|
||||
if ( is_unsafe_ipv6_literal( $host ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) \filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
|
||||
}
|
||||
|
||||
if ( 'localhost' === $host ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! \str_ends_with( $host, '.localhost' )
|
||||
&& ! \str_ends_with( $host, '.local' );
|
||||
},
|
||||
),
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
// No default so we can differentiate between Collection and CollectionPage requests.
|
||||
),
|
||||
'per_page' => array(
|
||||
'description' => 'Maximum number of items to be returned in result set.',
|
||||
'type' => 'integer',
|
||||
'default' => 20,
|
||||
'minimum' => 1,
|
||||
),
|
||||
'order' => array(
|
||||
'description' => 'Order sort attribute ascending or descending.',
|
||||
'type' => 'string',
|
||||
'default' => 'desc',
|
||||
'enum' => array( 'asc', 'desc' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,11 +178,6 @@ class Followers_Controller extends Actors_Controller {
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action triggered prior to the ActivityPub profile being created and sent to the client.
|
||||
@ -100,28 +189,40 @@ class Followers_Controller extends Actors_Controller {
|
||||
$page = $request->get_param( 'page' ) ?? 1;
|
||||
$context = $request->get_param( 'context' );
|
||||
|
||||
$data = Followers::get_followers_with_count( $user_id, $per_page, $page, array( 'order' => \ucwords( $order ) ) );
|
||||
$data = Followers::query( $user_id, $per_page, $page, array( 'order' => \ucwords( $order ) ) );
|
||||
|
||||
$response = array(
|
||||
'@context' => get_context(),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/followers', $user->get__id() ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => $user->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => $data['total'],
|
||||
'orderedItems' => array_map(
|
||||
function ( $item ) use ( $context ) {
|
||||
if ( 'full' === $context ) {
|
||||
return $item->to_array( false );
|
||||
}
|
||||
return $item->get_id();
|
||||
},
|
||||
$data['followers']
|
||||
),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/followers', $user_id ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => $data['total'],
|
||||
);
|
||||
|
||||
if ( 'full' === $context ) {
|
||||
// Ensure the context is the first element in the response.
|
||||
$response = array( '@context' => Base_Object::JSON_LD_CONTEXT ) + $response;
|
||||
}
|
||||
|
||||
if ( $this->show_social_graph( $request ) ) {
|
||||
$response['orderedItems'] = \array_filter(
|
||||
\array_map(
|
||||
static function ( $item ) use ( $context ) {
|
||||
if ( 'full' === $context ) {
|
||||
$actor = Remote_Actors::get_actor( $item );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return false;
|
||||
}
|
||||
return $actor->to_array( false );
|
||||
}
|
||||
return $item->guid;
|
||||
},
|
||||
$data['followers']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
@ -131,6 +232,122 @@ class Followers_Controller extends Actors_Controller {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves partial followers list for FEP-8fcf synchronization.
|
||||
*
|
||||
* Returns only followers whose ID shares the specified URI authority.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_partial_followers( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
|
||||
/*
|
||||
* Decode the percent-encoded authority once and use the canonical form
|
||||
* everywhere downstream. The route accepts authorities whose host has
|
||||
* percent-encoded octets (RFC 3986 reg-name), so the signer-host
|
||||
* check, the inbox LIKE query in Followers::get_by_authority(), and
|
||||
* the response `id` all need to agree on one canonical string. Mixing
|
||||
* raw and decoded forms would let a request pass the authority match
|
||||
* yet return an empty follower set, because stored inbox URLs are
|
||||
* unencoded.
|
||||
*/
|
||||
$authority = \rawurldecode( (string) $request->get_param( 'authority' ) );
|
||||
|
||||
/*
|
||||
* FEP-8fcf: the responding server MUST ensure the requested authority
|
||||
* matches the signing peer, so that instances cannot "get tricked
|
||||
* into requesting the followers list of a third-party individual".
|
||||
*/
|
||||
$signer_host = self::normalize_host( self::get_signer_host( $request ) );
|
||||
$asked_host = self::normalize_host( (string) \wp_parse_url( $authority, \PHP_URL_HOST ) );
|
||||
|
||||
if ( ! $signer_host || ! $asked_host || $signer_host !== $asked_host ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_authority_mismatch',
|
||||
\__( 'The authority parameter must match the signing peer.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$followers = Followers::get_by_authority( $user_id, $authority );
|
||||
$followers = \wp_list_pluck( $followers, 'guid' );
|
||||
|
||||
$response = array(
|
||||
'id' => get_rest_url_by_path(
|
||||
\sprintf(
|
||||
'actors/%d/followers/sync?authority=%s',
|
||||
$user_id,
|
||||
rawurlencode( $authority )
|
||||
)
|
||||
),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => count( $followers ),
|
||||
'orderedItems' => $followers,
|
||||
);
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a host so comparisons are consistent.
|
||||
*
|
||||
* Lowercases, strips IPv6 brackets, and trims a single FQDN trailing
|
||||
* dot. Used by both the validate_callback for the `authority` arg and
|
||||
* the signer-host comparison in get_partial_followers() so semantically
|
||||
* equivalent host strings always match.
|
||||
*
|
||||
* @param string $host Raw host string.
|
||||
* @return string Normalized host.
|
||||
*/
|
||||
private static function normalize_host( $host ) {
|
||||
$host = \strtolower( (string) $host );
|
||||
$host = \trim( $host, '[]' );
|
||||
return \rtrim( $host, '.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the signing peer's host from the request's HTTP Signature header.
|
||||
*
|
||||
* Supports both Cavage-style `Signature: keyId="…"` and RFC 9421's
|
||||
* `Signature-Input: …keyid="…"`. Returns the host component of the key
|
||||
* ID URI, lowercased, or an empty string when none is present.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return string The signer's host, or an empty string.
|
||||
*/
|
||||
private static function get_signer_host( $request ) {
|
||||
$signature = $request->get_header( 'signature' );
|
||||
$key_id = null;
|
||||
|
||||
if ( $signature && \preg_match( '/keyId="([^"]+)"/i', $signature, $matches ) ) {
|
||||
$key_id = $matches[1];
|
||||
} else {
|
||||
$signature_input = $request->get_header( 'signature-input' );
|
||||
if ( $signature_input && \preg_match( '/keyid="([^"]+)"/i', $signature_input, $matches ) ) {
|
||||
$key_id = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $key_id ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \strtolower( (string) \wp_parse_url( $key_id, \PHP_URL_HOST ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the followers schema, conforming to JSON Schema.
|
||||
*
|
||||
|
||||
@ -7,12 +7,13 @@
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
use function Activitypub\get_context;
|
||||
use function Activitypub\is_single_user;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* Following_Controller class.
|
||||
@ -24,13 +25,6 @@ use function Activitypub\get_masked_wp_version;
|
||||
class Following_Controller extends Actors_Controller {
|
||||
use Collection;
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public function __construct() {
|
||||
\add_filter( 'activitypub_rest_following', array( self::class, 'default_following' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
@ -41,16 +35,16 @@ class Following_Controller extends Actors_Controller {
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID or username of the actor.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '[\w\-\.]+',
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
@ -65,6 +59,18 @@ class Following_Controller extends Actors_Controller {
|
||||
'minimum' => 1,
|
||||
'maximum' => 100,
|
||||
),
|
||||
'order' => array(
|
||||
'description' => 'Order sort attribute ascending or descending.',
|
||||
'type' => 'string',
|
||||
'default' => 'desc',
|
||||
'enum' => array( 'asc', 'desc' ),
|
||||
),
|
||||
'context' => array(
|
||||
'description' => 'The context in which the request is made.',
|
||||
'type' => 'string',
|
||||
'default' => 'simple',
|
||||
'enum' => array( 'simple', 'full' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_public_item_schema' ),
|
||||
@ -80,10 +86,9 @@ class Following_Controller extends Actors_Controller {
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
$user = null;
|
||||
if ( \has_filter( 'activitypub_rest_following' ) ) {
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,27 +96,60 @@ class Following_Controller extends Actors_Controller {
|
||||
*/
|
||||
\do_action( 'activitypub_rest_following_pre' );
|
||||
|
||||
$order = $request->get_param( 'order' );
|
||||
$per_page = $request->get_param( 'per_page' );
|
||||
$page = $request->get_param( 'page' ) ?? 1;
|
||||
$context = $request->get_param( 'context' );
|
||||
|
||||
$data = Following::query( $user_id, $per_page, $page, array( 'order' => \ucwords( $order ) ) );
|
||||
|
||||
$response = array(
|
||||
'@context' => get_context(),
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/following', $user->get__id() ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => $user->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
'id' => get_rest_url_by_path( \sprintf( 'actors/%d/following', $user_id ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => $data['total'],
|
||||
);
|
||||
|
||||
if ( 'full' === $context ) {
|
||||
// Ensure the context is the first element in the response.
|
||||
$response = array( '@context' => Base_Object::JSON_LD_CONTEXT ) + $response;
|
||||
}
|
||||
|
||||
if ( $this->show_social_graph( $request ) ) {
|
||||
$response['orderedItems'] = \array_filter(
|
||||
\array_map(
|
||||
static function ( $item ) use ( $context ) {
|
||||
if ( 'full' === $context ) {
|
||||
$actor = Remote_Actors::get_actor( $item );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return false;
|
||||
}
|
||||
return $actor->to_array( false );
|
||||
}
|
||||
return $item->guid;
|
||||
},
|
||||
$data['following']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the list of following urls.
|
||||
* Filter the list of following urls
|
||||
*
|
||||
* @param array $items The array of following urls.
|
||||
* @param \Activitypub\Model\User $user The user object.
|
||||
*
|
||||
* @deprecated 7.1.0 Please migrate your Followings to the new internal Following structure.
|
||||
*/
|
||||
$items = \apply_filters( 'activitypub_rest_following', array(), $user );
|
||||
$items = \apply_filters_deprecated( 'activitypub_rest_following', array( array(), $user ), '7.1.0', 'Please migrate your Followings to the new internal Following structure.' );
|
||||
|
||||
$response['totalItems'] = \is_countable( $items ) ? \count( $items ) : 0;
|
||||
$response['orderedItems'] = $items;
|
||||
if ( ! empty( $items ) ) {
|
||||
$response['totalItems'] = count( $items );
|
||||
$response['orderedItems'] = $items;
|
||||
}
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
@ -121,29 +159,6 @@ class Following_Controller extends Actors_Controller {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Blog Authors to the following list of the Blog Actor
|
||||
* if Blog not in single mode.
|
||||
*
|
||||
* @param array $follow_list The array of following urls.
|
||||
* @param \Activitypub\Model\User $user The user object.
|
||||
*
|
||||
* @return array The array of following urls.
|
||||
*/
|
||||
public static function default_following( $follow_list, $user ) {
|
||||
if ( 0 !== $user->get__id() || is_single_user() ) {
|
||||
return $follow_list;
|
||||
}
|
||||
|
||||
$users = Actors::get_collection();
|
||||
|
||||
foreach ( $users as $user ) {
|
||||
$follow_list[] = $user->get_id();
|
||||
}
|
||||
|
||||
return $follow_list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the following schema, conforming to JSON Schema.
|
||||
*
|
||||
@ -154,9 +169,65 @@ class Following_Controller extends Actors_Controller {
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
// Define the schema for items in the following collection.
|
||||
$item_schema = array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'oneOf' => array(
|
||||
array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'id' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'type' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'name' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'icon' => array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'type' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'mediaType' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'url' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
),
|
||||
),
|
||||
'published' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'summary' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'updated' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'url' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'streams' => array(
|
||||
'type' => 'array',
|
||||
),
|
||||
'preferredUsername' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$schema = $this->get_collection_schema( $item_schema );
|
||||
|
||||
@ -9,10 +9,19 @@ namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Debug;
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Collection\Inbox;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Http;
|
||||
use Activitypub\Moderation;
|
||||
|
||||
use function Activitypub\is_same_domain;
|
||||
use function Activitypub\camel_to_snake_case;
|
||||
use function Activitypub\extract_recipients_from_activity;
|
||||
use function Activitypub\is_activity_public;
|
||||
use function Activitypub\is_collection;
|
||||
use function Activitypub\is_same_domain;
|
||||
use function Activitypub\object_to_uri;
|
||||
use function Activitypub\user_can_activitypub;
|
||||
|
||||
/**
|
||||
* Inbox_Controller class.
|
||||
@ -22,6 +31,9 @@ use function Activitypub\extract_recipients_from_activity;
|
||||
* @see https://www.w3.org/TR/activitypub/#inbox
|
||||
*/
|
||||
class Inbox_Controller extends \WP_REST_Controller {
|
||||
use Verification;
|
||||
use Language_Map;
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
@ -47,7 +59,7 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'create_item' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The unique identifier for the activity.',
|
||||
@ -62,21 +74,27 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
'sanitize_callback' => '\Activitypub\object_to_uri',
|
||||
),
|
||||
'type' => array(
|
||||
'description' => 'The type of the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => 'The type of the activity.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_html_class',
|
||||
'validate_callback' => static function ( $param ) {
|
||||
// Reject values that sanitize to empty so dynamic hook names always have a suffix.
|
||||
return '' !== \sanitize_html_class( (string) $param );
|
||||
},
|
||||
),
|
||||
'object' => array(
|
||||
'description' => 'The object of the activity.',
|
||||
'required' => true,
|
||||
'validate_callback' => function ( $param, $request, $key ) {
|
||||
'sanitize_callback' => array( $this, 'localize_language_maps' ),
|
||||
'validate_callback' => static function ( $param, $request, $key ) {
|
||||
/**
|
||||
* Filter the ActivityPub object validation.
|
||||
*
|
||||
* @param bool $validate The validation result.
|
||||
* @param array $param The object data.
|
||||
* @param object $request The request object.
|
||||
* @param string $key The key.
|
||||
* @param bool $validate The validation result.
|
||||
* @param array $param The object data.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param string $key The key.
|
||||
*/
|
||||
return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
|
||||
},
|
||||
@ -85,7 +103,7 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
'description' => 'The primary recipients of the activity.',
|
||||
'type' => array( 'string', 'array' ),
|
||||
'required' => false,
|
||||
'sanitize_callback' => function ( $param ) {
|
||||
'sanitize_callback' => static function ( $param ) {
|
||||
if ( \is_string( $param ) ) {
|
||||
$param = array( $param );
|
||||
}
|
||||
@ -96,7 +114,7 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
'cc' => array(
|
||||
'description' => 'The secondary recipients of the activity.',
|
||||
'type' => array( 'string', 'array' ),
|
||||
'sanitize_callback' => function ( $param ) {
|
||||
'sanitize_callback' => static function ( $param ) {
|
||||
if ( \is_string( $param ) ) {
|
||||
$param = array( $param );
|
||||
}
|
||||
@ -107,7 +125,7 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
'bcc' => array(
|
||||
'description' => 'The private recipients of the activity.',
|
||||
'type' => array( 'string', 'array' ),
|
||||
'sanitize_callback' => function ( $param ) {
|
||||
'sanitize_callback' => static function ( $param ) {
|
||||
if ( \is_string( $param ) ) {
|
||||
$param = array( $param );
|
||||
}
|
||||
@ -130,49 +148,149 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
* @return \WP_REST_Response|\WP_Error Response object or WP_Error.
|
||||
*/
|
||||
public function create_item( $request ) {
|
||||
$data = $request->get_json_params();
|
||||
$data = $request->get_json_params();
|
||||
$type = camel_to_snake_case( $request->get_param( 'type' ) );
|
||||
|
||||
/* @var Activity $activity Activity object.*/
|
||||
$activity = Activity::init_from_array( $data );
|
||||
$type = \strtolower( $request->get_param( 'type' ) );
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
if ( \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', '', $_SERVER['REMOTE_ADDR'], $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) {
|
||||
Debug::write_log( 'Blocked activity from: ' . $activity->get_actor() );
|
||||
if ( Moderation::activity_is_blocked( $activity ) ) {
|
||||
/**
|
||||
* ActivityPub inbox disallowed activity.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param null $user_id The user ID.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
*/
|
||||
\do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity );
|
||||
} else {
|
||||
$recipients = extract_recipients_from_activity( $data );
|
||||
$recipients = $this->get_local_recipients( $data );
|
||||
|
||||
foreach ( $recipients as $recipient ) {
|
||||
if ( ! is_same_domain( $recipient ) ) {
|
||||
continue;
|
||||
// Filter out blocked recipients.
|
||||
$allowed_recipients = array();
|
||||
foreach ( $recipients as $user_id ) {
|
||||
if ( Moderation::activity_is_blocked_for_user( $activity, $user_id ) ) {
|
||||
/**
|
||||
* ActivityPub inbox disallowed activity for specific user.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
*/
|
||||
\do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
|
||||
} else {
|
||||
$allowed_recipients[] = $user_id;
|
||||
|
||||
/**
|
||||
* ActivityPub inbox action.
|
||||
*
|
||||
* @deprecated 7.6.0 Support activitypub_inbox_shared instead to avoid duplicate processing.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request (shared_inbox when called from shared inbox endpoint).
|
||||
*/
|
||||
\do_action( 'activitypub_inbox', $data, $user_id, $type, $activity, Inbox::CONTEXT_SHARED_INBOX );
|
||||
|
||||
/**
|
||||
* ActivityPub inbox action for specific activity types.
|
||||
*
|
||||
* @deprecated 7.6.0 Support activitypub_inbox_shared_{type} instead to avoid duplicate processing.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request (shared_inbox when called from shared inbox endpoint).
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity, Inbox::CONTEXT_SHARED_INBOX );
|
||||
}
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_various( $recipient );
|
||||
/**
|
||||
* ActivityPub shared inbox action.
|
||||
*
|
||||
* This hook fires once per activity with all recipients.
|
||||
* Preferred for new implementations to avoid duplication.
|
||||
*
|
||||
* @since 7.6.0
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param array $recipients Array of user IDs.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request.
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_shared', $data, $allowed_recipients, $type, $activity, Inbox::CONTEXT_SHARED_INBOX );
|
||||
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
continue;
|
||||
}
|
||||
/**
|
||||
* ActivityPub shared inbox action for specific activity types.
|
||||
*
|
||||
* This hook fires once per activity with all recipients.
|
||||
* Preferred for new implementations to avoid duplication.
|
||||
*
|
||||
* @since 7.6.0
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param array $recipients Array of user IDs.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param string $context The context of the request.
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_shared_' . $type, $data, $allowed_recipients, $activity, Inbox::CONTEXT_SHARED_INBOX );
|
||||
|
||||
/**
|
||||
* Filter to skip inbox storage.
|
||||
*
|
||||
* Skip inbox storage for debugging purposes or to reduce load for
|
||||
* certain Activity-Types, like "Delete".
|
||||
*
|
||||
* @param bool $skip Whether to skip inbox storage.
|
||||
* @param array $data The activity data array.
|
||||
*
|
||||
* @return bool Whether to skip inbox storage.
|
||||
*/
|
||||
$skip = \apply_filters( 'activitypub_skip_inbox_storage', false, $data );
|
||||
|
||||
if ( ! $skip ) {
|
||||
$result = Inbox::add( $activity, $allowed_recipients );
|
||||
|
||||
/**
|
||||
* ActivityPub inbox action.
|
||||
* Fires after an ActivityPub Inbox activity has been handled.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $user_ids The user IDs.
|
||||
* @param string $type The type of the activity.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param \WP_Error|int $result The ID of the inbox item that was created, or WP_Error if failed.
|
||||
* @param string $context The context of the request ('inbox' or 'shared_inbox').
|
||||
*/
|
||||
\do_action( 'activitypub_inbox', $data, $actor->get__id(), $type, $activity );
|
||||
\do_action( 'activitypub_handled_inbox', $data, $allowed_recipients, $type, $activity, $result, Inbox::CONTEXT_SHARED_INBOX );
|
||||
|
||||
/**
|
||||
* ActivityPub inbox action for specific activity types.
|
||||
* Fires after an ActivityPub Inbox activity has been handled.
|
||||
*
|
||||
* @param array $data The data array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $user_ids The user IDs.
|
||||
* @param Activity|\WP_Error $activity The Activity object.
|
||||
* @param \WP_Error|int $result The ID of the inbox item that was created, or WP_Error if failed.
|
||||
* @param string $context The context of the request ('inbox' or 'shared_inbox').
|
||||
*/
|
||||
\do_action( 'activitypub_inbox_' . $type, $data, $actor->get__id(), $activity );
|
||||
\do_action( 'activitypub_handled_inbox_' . $type, $data, $allowed_recipients, $activity, $result, Inbox::CONTEXT_SHARED_INBOX );
|
||||
}
|
||||
}
|
||||
|
||||
$response = \rest_ensure_response( array() );
|
||||
$response = \rest_ensure_response(
|
||||
array(
|
||||
'type' => 'https://w3id.org/fep/c180#approval-required',
|
||||
'title' => 'Approval Required',
|
||||
'status' => '202',
|
||||
'detail' => 'This activity requires approval before it can be processed.',
|
||||
)
|
||||
);
|
||||
$response->set_status( 202 );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
@ -252,4 +370,169 @@ class Inbox_Controller extends \WP_REST_Controller {
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recipients from the given Activity.
|
||||
*
|
||||
* @param array $activity The activity data.
|
||||
*
|
||||
* @return array An array of user IDs who are the recipients of the activity.
|
||||
*/
|
||||
private function get_local_recipients( $activity ) {
|
||||
$user_ids = array();
|
||||
$remote_fetches = 0;
|
||||
$cap_notified = false;
|
||||
|
||||
/**
|
||||
* Filters the maximum number of remote recipient URLs that can be
|
||||
* fetched per incoming activity.
|
||||
*
|
||||
* @since 8.2.1
|
||||
*
|
||||
* @param int $max_remote_fetches Maximum number of remote fetches. Default 10.
|
||||
*/
|
||||
$max_remote_fetches = (int) \apply_filters( 'activitypub_max_remote_recipient_fetches', 10 );
|
||||
|
||||
// AS2 allows actor and followers to be either an IRI string or an inline object; normalize to a URI.
|
||||
$actor_uri = ! empty( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
|
||||
$actor_followers_url = $this->get_cached_followers_url( $actor_uri );
|
||||
|
||||
if ( is_activity_public( $activity ) ) {
|
||||
$user_ids = Following::get_follower_ids( $actor_uri );
|
||||
}
|
||||
|
||||
$recipients = extract_recipients_from_activity( $activity );
|
||||
|
||||
/*
|
||||
* Pre-compute which recipients are already known remote actors so the
|
||||
* cached-actor short-circuit becomes an O(1) array lookup rather than
|
||||
* one DB query per recipient. This bounds the DB cost of a flood of
|
||||
* unknown recipient URIs to one batched SELECT (chunked) regardless
|
||||
* of how many were sent.
|
||||
*/
|
||||
$candidate_uris = array();
|
||||
foreach ( $recipients as $recipient ) {
|
||||
if (
|
||||
! \is_string( $recipient )
|
||||
|| \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true )
|
||||
|| is_same_domain( $recipient )
|
||||
|| $recipient === $actor_followers_url
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$candidate_uris[] = $recipient;
|
||||
}
|
||||
$cached_uris = $candidate_uris ? Remote_Actors::get_existing_uris( $candidate_uris ) : array();
|
||||
|
||||
foreach ( $recipients as $recipient ) {
|
||||
// Skip public audience identifiers - they're not actual recipients to fetch.
|
||||
if ( \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! is_same_domain( $recipient ) ) {
|
||||
// Known followers collection: resolve from local DB, no fetch needed.
|
||||
if ( $recipient === $actor_followers_url ) {
|
||||
$user_ids = array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already cached as a remote actor: not a collection, so no local recipients to add.
|
||||
if ( isset( $cached_uris[ $recipient ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown URL: cap remote fetches to prevent abuse via large audience/recipient fields.
|
||||
if ( $remote_fetches >= $max_remote_fetches ) {
|
||||
if ( ! $cap_notified ) {
|
||||
$cap_notified = true;
|
||||
|
||||
/**
|
||||
* Fires when an incoming activity hits the remote recipient fetch cap.
|
||||
*
|
||||
* Fires once per activity on the first recipient that exceeds the cap,
|
||||
* not for each subsequent skipped recipient. Hook this to surface
|
||||
* cap hits in your logging system of choice (Jetpack, Sentry, syslog, etc.).
|
||||
*
|
||||
* @since 8.2.1
|
||||
*
|
||||
* @param array $activity The incoming activity data.
|
||||
* @param string $recipient The recipient URI that was skipped.
|
||||
* @param int $cap The configured cap.
|
||||
*/
|
||||
\do_action( 'activitypub_remote_recipient_fetch_cap_reached', $activity, $recipient, $max_remote_fetches );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
++$remote_fetches;
|
||||
|
||||
$collection = Http::get_remote_object( $recipient );
|
||||
|
||||
if ( \is_wp_error( $collection ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( is_collection( $collection ) ) {
|
||||
$user_ids = array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$user_id = Actors::get_id_by_resource( $recipient );
|
||||
|
||||
if ( \is_wp_error( $user_id ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! user_can_activitypub( $user_id ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user_ids[] = $user_id;
|
||||
}
|
||||
|
||||
// Check for an Actor in the Object field.
|
||||
if ( empty( $user_ids ) && ! empty( $activity['object'] ) ) {
|
||||
$user_id = Actors::get_id_by_resource( $activity['object'] );
|
||||
|
||||
if ( ! \is_wp_error( $user_id ) && user_can_activitypub( $user_id ) ) {
|
||||
$user_ids[] = $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique( array_map( 'intval', $user_ids ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an actor's followers collection URL from the cached profile.
|
||||
*
|
||||
* Used to detect followers-addressed recipients without an outbound fetch.
|
||||
*
|
||||
* @param string|null $actor_uri Normalized actor URI.
|
||||
*
|
||||
* @return string|null The followers collection URL, or null if not cached/available.
|
||||
*/
|
||||
private function get_cached_followers_url( $actor_uri ) {
|
||||
if ( empty( $actor_uri ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor_post = Remote_Actors::get_by_uri( $actor_uri );
|
||||
if ( \is_wp_error( $actor_post ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match Remote_Actors::get_actor()'s storage fallback: legacy actor JSON lives in postmeta when post_content is empty.
|
||||
$json = $actor_post->post_content;
|
||||
if ( empty( $json ) ) {
|
||||
$json = \get_post_meta( $actor_post->ID, '_activitypub_actor_json', true );
|
||||
}
|
||||
|
||||
$actor_data = \json_decode( $json, true );
|
||||
if ( empty( $actor_data['followers'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return object_to_uri( $actor_data['followers'] );
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,12 @@
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\user_can_activitypub;
|
||||
|
||||
/**
|
||||
* Interaction Controller.
|
||||
*/
|
||||
@ -40,11 +44,16 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'uri' => array(
|
||||
'description' => 'The URI of the object to interact with.',
|
||||
'uri' => array(
|
||||
'description' => 'The URI or webfinger ID of the object to interact with.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => array( $this, 'sanitize_uri' ),
|
||||
),
|
||||
'intent' => array(
|
||||
'description' => 'The intent of the interaction, e.g., follow, reply, import.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'required' => true,
|
||||
'enum' => array_map( 'Activitypub\camel_to_snake_case', Activity::TYPES ),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -52,6 +61,29 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the URI parameter.
|
||||
*
|
||||
* @param string $uri The URI or webfinger ID of the object to interact with.
|
||||
*
|
||||
* @return string Sanitized URI.
|
||||
*/
|
||||
public function sanitize_uri( $uri ) {
|
||||
// Remove "acct:" prefix if present.
|
||||
if ( str_starts_with( $uri, 'acct:' ) ) {
|
||||
$uri = \substr( $uri, 5 );
|
||||
}
|
||||
|
||||
// Remove "@" prefix if present.
|
||||
$uri = \ltrim( $uri, '@' );
|
||||
|
||||
if ( is_email( $uri ) ) {
|
||||
return \sanitize_text_field( $uri );
|
||||
}
|
||||
|
||||
return \sanitize_url( $uri );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the interaction URL for a given URI.
|
||||
*
|
||||
@ -61,6 +93,7 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
*/
|
||||
public function get_item( $request ) {
|
||||
$uri = $request->get_param( 'uri' );
|
||||
$intent = $request->get_param( 'intent' );
|
||||
$redirect_url = '';
|
||||
$object = Http::get_remote_object( $uri );
|
||||
|
||||
@ -76,27 +109,58 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $object['url'] ) ) {
|
||||
$uri = \esc_url( $object['url'] );
|
||||
if ( ! empty( $object['id'] ) ) {
|
||||
$uri = \esc_url( $object['id'] );
|
||||
}
|
||||
|
||||
// Prepare URL parameter.
|
||||
$url_param = \rawurlencode( $uri );
|
||||
|
||||
switch ( $object['type'] ) {
|
||||
case 'Group':
|
||||
case 'Person':
|
||||
case 'Service':
|
||||
case 'Application':
|
||||
case 'Organization':
|
||||
if ( \get_option( 'activitypub_following_ui', '0' ) ) {
|
||||
if ( user_can_activitypub( \get_current_user_id() ) ) {
|
||||
$redirect_url = \admin_url( 'users.php?page=activitypub-following-list&resource=' . $url_param );
|
||||
} elseif ( user_can_activitypub( Actors::BLOG_USER_ID ) ) {
|
||||
$redirect_url = \admin_url( 'options-general.php?page=activitypub&tab=following&resource=' . $url_param );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the URL used for following an ActivityPub actor.
|
||||
*
|
||||
* @param string $redirect_url The URL to redirect to.
|
||||
* @param string $uri The URI of the actor to follow.
|
||||
* @param array $object The full actor object data.
|
||||
* @param string $intent The intent of the interaction.
|
||||
*/
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_follow_url', $redirect_url, $uri, $object );
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_follow_url', $redirect_url, $uri, $object, $intent );
|
||||
break;
|
||||
case 'Collection':
|
||||
case 'CollectionPage':
|
||||
case 'OrderedCollection':
|
||||
case 'OrderedCollectionPage':
|
||||
if ( \get_option( 'activitypub_following_ui', '0' ) ) {
|
||||
$redirect_url = \admin_url( 'admin.php?import=starter-kit&url=' . $url_param );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the URL used for importing a Starter Kit collection.
|
||||
*
|
||||
* @param string $redirect_url The URL to redirect to.
|
||||
* @param string $uri The URI of the collection to import.
|
||||
* @param array $object The full collection object data.
|
||||
* @param string $intent The intent of the interaction.
|
||||
*/
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_starter_kit_url', $redirect_url, $uri, $object, $intent );
|
||||
break;
|
||||
default:
|
||||
$redirect_url = \admin_url( 'post-new.php?in_reply_to=' . $uri );
|
||||
$redirect_url = \admin_url( 'post-new.php?in_reply_to=' . $url_param );
|
||||
|
||||
/**
|
||||
* Filters the URL used for replying to an ActivityPub object.
|
||||
*
|
||||
@ -105,8 +169,9 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
* @param string $redirect_url The URL to redirect to.
|
||||
* @param string $uri The URI of the object to reply to.
|
||||
* @param array $object The full object data being replied to.
|
||||
* @param string $intent The intent of the interaction.
|
||||
*/
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_reply_url', $redirect_url, $uri, $object );
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_reply_url', $redirect_url, $uri, $object, $intent );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,8 +183,9 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
* @param string $redirect_url The URL to redirect to.
|
||||
* @param string $uri The URI of the object.
|
||||
* @param array $object The object being interacted with.
|
||||
* @param string $intent The intent of the interaction.
|
||||
*/
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_url', $redirect_url, $uri, $object );
|
||||
$redirect_url = \apply_filters( 'activitypub_interactions_url', $redirect_url, $uri, $object, $intent );
|
||||
|
||||
// Check if hook is implemented.
|
||||
if ( ! $redirect_url ) {
|
||||
@ -134,6 +200,6 @@ class Interaction_Controller extends \WP_REST_Controller {
|
||||
);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( null, 302, array( 'Location' => \esc_url( $redirect_url ) ) );
|
||||
return new \WP_REST_Response( null, 302, array( 'Location' => $redirect_url ) );
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* Liked_Controller file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Outbox;
|
||||
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* ActivityPub Liked Controller.
|
||||
*
|
||||
* Serves the `liked` collection for an actor, listing all objects
|
||||
* the actor has liked that have not been subsequently undone.
|
||||
*
|
||||
* @see https://www.w3.org/TR/activitypub/#liked
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
class Liked_Controller extends Actors_Controller {
|
||||
use Collection;
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/liked',
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
// No default so we can differentiate between Collection and CollectionPage requests.
|
||||
),
|
||||
'per_page' => array(
|
||||
'description' => 'Maximum number of items to be returned in result set.',
|
||||
'type' => 'integer',
|
||||
'default' => 20,
|
||||
'minimum' => 1,
|
||||
'maximum' => 100,
|
||||
),
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the liked collection for an actor.
|
||||
*
|
||||
* Queries the outbox for Like activities, excluding those that
|
||||
* have been subsequently undone. Handles re-likes correctly by
|
||||
* checking the most recent activity for each object.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object.
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$page = $request->get_param( 'page' );
|
||||
$per_page = $request->get_param( 'per_page' );
|
||||
|
||||
$liked_objects = $this->get_liked_object_ids( $user_id );
|
||||
|
||||
// Paginate the results.
|
||||
$offset = ( null !== $page ) ? ( $page - 1 ) * $per_page : 0;
|
||||
$ordered_items = array_slice( $liked_objects, $offset, $per_page );
|
||||
|
||||
$response = array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'actors/%d/liked', $user_id ) ),
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => Actors::get_by_id( $user_id )->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => count( $liked_objects ),
|
||||
'orderedItems' => $ordered_items,
|
||||
);
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently liked object IDs for an actor.
|
||||
*
|
||||
* Queries all Like and Undo activities from the outbox, ordered
|
||||
* newest first. For each unique object ID, the most recent
|
||||
* activity determines whether the object is still liked.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param int $user_id The actor user ID.
|
||||
* @return string[] Array of liked object URLs.
|
||||
*/
|
||||
private function get_liked_object_ids( $user_id ) {
|
||||
$args = array(
|
||||
// phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
|
||||
'posts_per_page' => 1000,
|
||||
'post_type' => Outbox::POST_TYPE,
|
||||
'post_status' => 'any',
|
||||
'orderby' => array(
|
||||
'date' => 'DESC',
|
||||
'ID' => 'DESC',
|
||||
),
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_activitypub_activity_actor',
|
||||
'value' => Actors::get_type_by_id( $user_id ),
|
||||
),
|
||||
array(
|
||||
'key' => '_activitypub_activity_type',
|
||||
'value' => array( 'Like', 'Undo' ),
|
||||
'compare' => 'IN',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if ( $user_id > 0 ) {
|
||||
$args['author'] = $user_id;
|
||||
}
|
||||
|
||||
$posts = \get_posts( $args );
|
||||
|
||||
\update_postmeta_cache( \wp_list_pluck( $posts, 'ID' ) );
|
||||
|
||||
/*
|
||||
* Walk newest-first. For each unique object ID, the first
|
||||
* occurrence determines the current state: if it is a Like
|
||||
* the object is still liked, if it is an Undo the like was
|
||||
* revoked. Skip any object ID we have already seen.
|
||||
*/
|
||||
$seen = array();
|
||||
$liked = array();
|
||||
|
||||
foreach ( $posts as $post ) {
|
||||
$object_id = \get_post_meta( $post->ID, '_activitypub_object_id', true );
|
||||
$activity_type = \get_post_meta( $post->ID, '_activitypub_activity_type', true );
|
||||
|
||||
if ( ! $object_id || isset( $seen[ $object_id ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[ $object_id ] = true;
|
||||
|
||||
if ( 'Like' === $activity_type ) {
|
||||
$liked[] = $object_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $liked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the schema for the liked endpoint.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @return array Schema data.
|
||||
*/
|
||||
public function get_item_schema() {
|
||||
if ( $this->schema ) {
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
$item_schema = array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
);
|
||||
|
||||
$schema = $this->get_collection_schema( $item_schema );
|
||||
|
||||
$schema['title'] = 'liked';
|
||||
$schema['properties']['actor'] = array(
|
||||
'description' => 'The actor who owns this liked collection.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'required' => true,
|
||||
);
|
||||
$schema['properties']['generator'] = array(
|
||||
'description' => 'The software used to generate the collection.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
);
|
||||
|
||||
$this->schema = $schema;
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
}
|
||||
@ -9,13 +9,13 @@ namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
|
||||
use function Activitypub\get_context;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* ActivityPub Moderators_Controller class.
|
||||
*/
|
||||
class Moderators_Controller extends \WP_REST_Controller {
|
||||
use Collection;
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
@ -43,6 +43,26 @@ class Moderators_Controller extends \WP_REST_Controller {
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
// No default so we can differentiate between Collection and CollectionPage requests.
|
||||
),
|
||||
'per_page' => array(
|
||||
'description' => 'Maximum number of items to be returned in result set.',
|
||||
'type' => 'integer',
|
||||
'default' => 100,
|
||||
'minimum' => 1,
|
||||
),
|
||||
'order' => array(
|
||||
'description' => 'Order sort attribute ascending or descending.',
|
||||
'type' => 'string',
|
||||
'default' => 'desc',
|
||||
'enum' => array( 'asc', 'desc' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
@ -70,12 +90,22 @@ class Moderators_Controller extends \WP_REST_Controller {
|
||||
$actors = apply_filters( 'activitypub_rest_moderators', $actors );
|
||||
|
||||
$response = array(
|
||||
'@context' => get_context(),
|
||||
'id' => get_rest_url_by_path( 'collections/moderators' ),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => \count( $actors ),
|
||||
'orderedItems' => $actors,
|
||||
);
|
||||
|
||||
// Set the JSON-LD context if not already set.
|
||||
if ( empty( $response['@context'] ) ) {
|
||||
// Ensure the context is the first element in the response.
|
||||
$response = array( '@context' => $this->json_ld_context ) + $response;
|
||||
}
|
||||
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
@ -122,6 +152,11 @@ class Moderators_Controller extends \WP_REST_Controller {
|
||||
),
|
||||
'required' => true,
|
||||
),
|
||||
'totalItems' => array(
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -8,8 +8,6 @@
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function Activitypub\get_total_users;
|
||||
use function Activitypub\get_active_users;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
@ -54,7 +52,7 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/(?P<version>\d\.\d)',
|
||||
array(
|
||||
'args' => array(
|
||||
'args' => array(
|
||||
'version' => array(
|
||||
'description' => 'The version of the NodeInfo schema.',
|
||||
'type' => 'string',
|
||||
@ -66,6 +64,7 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => '__return_true',
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -93,6 +92,14 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
'rel' => 'https://nodeinfo.diaspora.software/ns/schema/2.0',
|
||||
'href' => get_rest_url_by_path( '/nodeinfo/2.0' ),
|
||||
),
|
||||
array(
|
||||
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
'href' => get_rest_url_by_path( '/nodeinfo/2.1' ),
|
||||
),
|
||||
array(
|
||||
'rel' => 'https://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
'href' => get_rest_url_by_path( '/nodeinfo/2.1' ),
|
||||
),
|
||||
array(
|
||||
'rel' => 'https://www.w3.org/ns/activitystreams#Application',
|
||||
'href' => get_rest_url_by_path( 'application' ),
|
||||
@ -121,7 +128,8 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
|
||||
switch ( $version ) {
|
||||
case '2.0':
|
||||
$response = $this->get_version_2_0();
|
||||
case '2.1':
|
||||
$response = $this->get_version_2_X( $version );
|
||||
break;
|
||||
|
||||
default:
|
||||
@ -135,31 +143,23 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* Get the NodeInfo 2.0 data.
|
||||
*
|
||||
* @return array
|
||||
* @param string $version The NodeInfo version.
|
||||
*
|
||||
* @return array The NodeInfo data.
|
||||
*/
|
||||
public function get_version_2_0() {
|
||||
public function get_version_2_X( $version ) {
|
||||
$posts = \wp_count_posts();
|
||||
$comments = \wp_count_comments();
|
||||
|
||||
return array(
|
||||
'version' => '2.0',
|
||||
$nodeinfo = array(
|
||||
'version' => $version,
|
||||
'software' => array(
|
||||
'name' => 'wordpress',
|
||||
'version' => get_masked_wp_version(),
|
||||
),
|
||||
'protocols' => array( 'activitypub' ),
|
||||
'services' => array(
|
||||
'inbound' => array(),
|
||||
'outbound' => array(),
|
||||
),
|
||||
'openRegistrations' => (bool) get_option( 'users_can_register' ),
|
||||
'usage' => array(
|
||||
'users' => array(
|
||||
'total' => get_total_users(),
|
||||
'activeHalfyear' => get_active_users( 6 ),
|
||||
'activeMonth' => get_active_users(),
|
||||
),
|
||||
'localPosts' => $posts->publish,
|
||||
'localPosts' => (int) $posts->publish,
|
||||
'localComments' => $comments->approved,
|
||||
),
|
||||
'metadata' => array(
|
||||
@ -168,5 +168,181 @@ class Nodeinfo_Controller extends \WP_REST_Controller {
|
||||
'nodeIcon' => \get_site_icon_url(),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter the NodeInfo data.
|
||||
*
|
||||
* @param array $nodeinfo The NodeInfo data.
|
||||
* @param string $version The NodeInfo version.
|
||||
*/
|
||||
return \apply_filters( 'nodeinfo_data', $nodeinfo, $version );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the NodeInfo schema, conforming to JSON Schema.
|
||||
*
|
||||
* @return array NodeInfo schema data.
|
||||
*/
|
||||
public function get_item_schema() {
|
||||
return array(
|
||||
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
||||
'title' => 'nodeinfo',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'version' => array(
|
||||
'description' => 'The version of the NodeInfo schema.',
|
||||
'type' => 'string',
|
||||
'enum' => array( '2.0', '2.1' ),
|
||||
),
|
||||
'software' => array(
|
||||
'description' => 'Information about the software.',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'name' => array(
|
||||
'description' => 'The canonical name of this server software.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'version' => array(
|
||||
'description' => 'The version of this server software.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'homepage' => array(
|
||||
'description' => 'The url of the homepage of this server software.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'repository' => array(
|
||||
'description' => 'The url of the source code repository of this server software.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
),
|
||||
),
|
||||
'protocols' => array(
|
||||
'description' => 'The protocols supported on this server.',
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'string',
|
||||
'enum' => array(
|
||||
'activitypub',
|
||||
'buddycloud',
|
||||
'dfrn',
|
||||
'diaspora',
|
||||
'libertree',
|
||||
'ostatus',
|
||||
'pumpio',
|
||||
'tent',
|
||||
'xmpp',
|
||||
'zot',
|
||||
),
|
||||
),
|
||||
),
|
||||
'services' => array(
|
||||
'description' => 'The third party sites this server can connect to via their application API.',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'inbound' => array(
|
||||
'description' => 'The third party sites this server can retrieve messages from for combined display with regular traffic.',
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'string',
|
||||
'enum' => array(
|
||||
'atom1.0',
|
||||
'gnusocial',
|
||||
'imap',
|
||||
'pnut',
|
||||
'pop3',
|
||||
'pumpio',
|
||||
'rss2.0',
|
||||
'twitter',
|
||||
),
|
||||
),
|
||||
),
|
||||
'outbound' => array(
|
||||
'description' => 'The third party sites this server can publish messages to on the behalf of a user.',
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'string',
|
||||
'enum' => array(
|
||||
'atom1.0',
|
||||
'blogger',
|
||||
'buddycloud',
|
||||
'diaspora',
|
||||
'dreamwidth',
|
||||
'drupal',
|
||||
'facebook',
|
||||
'friendica',
|
||||
'gnusocial',
|
||||
'google',
|
||||
'insanejournal',
|
||||
'libertree',
|
||||
'linkedin',
|
||||
'livejournal',
|
||||
'mediagoblin',
|
||||
'myspace',
|
||||
'pinterest',
|
||||
'pnut',
|
||||
'posterous',
|
||||
'pumpio',
|
||||
'redmatrix',
|
||||
'rss2.0',
|
||||
'smtp',
|
||||
'tent',
|
||||
'tumblr',
|
||||
'twitter',
|
||||
'wordpress',
|
||||
'xmpp',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
'openRegistrations' => array(
|
||||
'description' => 'Whether this server allows open self-registration.',
|
||||
'type' => 'boolean',
|
||||
),
|
||||
'usage' => array(
|
||||
'description' => 'Usage statistics for this server.',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'users' => array(
|
||||
'description' => 'Statistics about the users of this server.',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'total' => array(
|
||||
'description' => 'The total amount of on this server registered users.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
),
|
||||
'activeMonth' => array(
|
||||
'description' => 'The amount of users that signed in at least once in the last 30 days.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
),
|
||||
'activeHalfyear' => array(
|
||||
'description' => 'The amount of users that signed in at least once in the last 180 days.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
'localPosts' => array(
|
||||
'description' => 'The amount of posts that were made by users that are registered on this server.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
),
|
||||
'localComments' => array(
|
||||
'description' => 'The amount of comments that were made by users that are registered on this server.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
'metadata' => array(
|
||||
'description' => 'Free form key value pairs for software specific values.',
|
||||
'type' => 'object',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,11 +7,18 @@
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Outbox;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\extract_recipients_from_activity;
|
||||
use function Activitypub\get_masked_wp_version;
|
||||
use function ActivityPub\get_rest_url_by_path;
|
||||
use function Activitypub\get_object_id;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\object_to_uri;
|
||||
use function Activitypub\user_can_act_as_blog;
|
||||
|
||||
/**
|
||||
* ActivityPub Outbox Controller.
|
||||
@ -22,6 +29,16 @@ use function ActivityPub\get_rest_url_by_path;
|
||||
*/
|
||||
class Outbox_Controller extends \WP_REST_Controller {
|
||||
use Collection;
|
||||
use Event_Stream;
|
||||
use Language_Map;
|
||||
use Verification;
|
||||
|
||||
/**
|
||||
* Activity types accessible as individual outbox items via REST.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
const PUBLIC_ACTIVITY_TYPES = array( 'Announce', 'Arrive', 'Create', 'Like', 'Update' );
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
@ -35,7 +52,7 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = '(?:users|actors)/(?P<user_id>[\w\-\.]+)/outbox';
|
||||
protected $rest_base = '(?:users|actors)/(?P<user_id>[-]?\d+)/outbox';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
@ -48,14 +65,14 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the user or actor.',
|
||||
'type' => 'string',
|
||||
'type' => 'integer',
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
|
||||
'permission_callback' => array( $this, 'verify_signature' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'description' => 'Current page of the collection.',
|
||||
@ -72,9 +89,38 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'create_item' ),
|
||||
'permission_callback' => array( $this, 'verify_authentication' ),
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/stream',
|
||||
array(
|
||||
'args' => array(
|
||||
'user_id' => array(
|
||||
'description' => 'The ID of the actor.',
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => function ( $request ) {
|
||||
$this->stream_collection( $request->get_param( 'user_id' ), 'outbox' );
|
||||
},
|
||||
'permission_callback' => array( $this, 'get_stream_permissions_check' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
\add_filter( 'activitypub_rest_outbox_array', array( $this, 'overload_total_items' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,7 +130,7 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
* @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise.
|
||||
*/
|
||||
public function validate_user_id( $user_id ) {
|
||||
$user = Actors::get_by_various( $user_id );
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
@ -100,8 +146,8 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$page = $request->get_param( 'page' ) ?? 1;
|
||||
$user = Actors::get_by_various( $request->get_param( 'user_id' ) );
|
||||
$user_id = $user->get__id();
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
|
||||
/**
|
||||
* Action triggered prior to the ActivityPub profile being created and sent to the client.
|
||||
@ -111,11 +157,11 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
\do_action( 'activitypub_rest_outbox_pre', $request );
|
||||
|
||||
/**
|
||||
* Filters the list of activity types to include in the outbox.
|
||||
* Filters the activity types included in the outbox collection.
|
||||
*
|
||||
* @param string[] $activity_types The list of activity types.
|
||||
* @param string[] $activity_types The activity types.
|
||||
*/
|
||||
$activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) );
|
||||
$activity_types = \apply_filters( 'activitypub_outbox_activity_types', self::PUBLIC_ACTIVITY_TYPES );
|
||||
|
||||
$args = array(
|
||||
'posts_per_page' => $request->get_param( 'per_page' ),
|
||||
@ -133,7 +179,26 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
),
|
||||
);
|
||||
|
||||
if ( get_current_user_id() !== $user_id && ! current_user_can( 'activitypub' ) ) {
|
||||
/*
|
||||
* Whether the current user owns the outbox being queried. Owners see private
|
||||
* and non-public activity types without the visibility filters below.
|
||||
*
|
||||
* For the blog actor (user_id = 0) the identity-equality check is wrong on
|
||||
* two counts — `get_current_user_id()` returns 0 for anonymous visitors (so
|
||||
* `0 === 0` would leak everything to the public), and AP-capable authors
|
||||
* would otherwise pass the `current_user_can( 'activitypub' )` arm and read
|
||||
* the blog's private Accepts. Delegate to the capability helper instead.
|
||||
*/
|
||||
if ( Actors::BLOG_USER_ID === (int) $user_id ) {
|
||||
$is_outbox_owner = user_can_act_as_blog();
|
||||
} else {
|
||||
$is_outbox_owner = \is_user_logged_in() && (
|
||||
\get_current_user_id() === (int) $user_id
|
||||
|| \current_user_can( 'activitypub' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! $is_outbox_owner ) {
|
||||
$args['meta_query'][] = array(
|
||||
'key' => '_activitypub_activity_type',
|
||||
'value' => $activity_types,
|
||||
@ -161,7 +226,7 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
* @param array $args Array of arguments for WP_Query.
|
||||
* @param \WP_REST_Request $request The REST API request.
|
||||
*/
|
||||
$args = apply_filters( 'rest_activitypub_outbox_query', $args, $request );
|
||||
$args = \apply_filters( 'activitypub_rest_outbox_query', $args, $request );
|
||||
|
||||
$outbox_query = new \WP_Query();
|
||||
$query_result = $outbox_query->query( $args );
|
||||
@ -172,17 +237,38 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
|
||||
'actor' => $user->get_id(),
|
||||
'type' => 'OrderedCollection',
|
||||
'totalItems' => $outbox_query->found_posts,
|
||||
'totalItems' => (int) $outbox_query->found_posts,
|
||||
'eventStream' => $this->get_stream_url( $user_id, 'outbox' ),
|
||||
'orderedItems' => array(),
|
||||
);
|
||||
|
||||
\update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
|
||||
foreach ( $query_result as $outbox_item ) {
|
||||
$response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request );
|
||||
if ( ! $outbox_item instanceof \WP_Post ) {
|
||||
/**
|
||||
* Action triggered when an outbox item is not a WP_Post.
|
||||
*
|
||||
* @param mixed $outbox_item The outbox item.
|
||||
* @param array $args The arguments used to query the outbox.
|
||||
* @param array $query_result The result of the query.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
\do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request );
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $this->prepare_item_for_response( $outbox_item, $request );
|
||||
|
||||
if ( \is_wp_error( $item ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$response['orderedItems'][] = $item;
|
||||
}
|
||||
|
||||
$response = $this->prepare_collection_response( $response, $request );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
if ( \is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
@ -199,7 +285,7 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*/
|
||||
\do_action( 'activitypub_outbox_post', $request );
|
||||
\do_action( 'activitypub_rest_outbox_post', $request );
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
@ -217,6 +303,10 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$activity = Outbox::get_activity( $item->ID );
|
||||
|
||||
if ( \is_wp_error( $activity ) ) {
|
||||
return $activity;
|
||||
}
|
||||
|
||||
return $activity->to_array( false );
|
||||
}
|
||||
|
||||
@ -254,4 +344,348 @@ class Outbox_Controller extends \WP_REST_Controller {
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
/**
|
||||
* Overload total items for public requests.
|
||||
*
|
||||
* For unauthenticated (public) requests, the `totalItems` property shows
|
||||
* the overall number of federated posts and comments, which is what
|
||||
* Mastodon expects for display purposes.
|
||||
*
|
||||
* For authenticated C2S requests, we skip this override so that totalItems
|
||||
* accurately reflects the actual outbox collection size.
|
||||
*
|
||||
* @param array $response The response array.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return array The modified response array.
|
||||
*/
|
||||
public function overload_total_items( $response, $request ) {
|
||||
// For authenticated requests, return accurate totalItems matching orderedItems.
|
||||
if ( \get_current_user_id() ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$posts = new \WP_Query(
|
||||
array(
|
||||
'post_status' => 'publish',
|
||||
'author' => $request->get_param( 'user_id' ),
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => 'activitypub_status',
|
||||
'compare' => 'EXISTS',
|
||||
),
|
||||
),
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => false,
|
||||
'number' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
$user_id = (int) $request->get_param( 'user_id' );
|
||||
$comments = new \WP_Comment_Query(
|
||||
array(
|
||||
'status' => 'approve',
|
||||
'user_id' => $user_id,
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_key' => 'activitypub_status',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => false,
|
||||
'number' => 1,
|
||||
'author__not_in' => array( 0 ),
|
||||
)
|
||||
);
|
||||
|
||||
$response['totalItems'] = (int) $posts->found_posts + (int) $comments->found_comments;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an item in the outbox.
|
||||
*
|
||||
* Fires handlers via filter to process the activity. Handlers are responsible
|
||||
* for calling add_to_outbox() and returning the outbox_id.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
|
||||
*/
|
||||
public function create_item( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
|
||||
if ( empty( $data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_request',
|
||||
\__( 'Request body must be a valid ActivityPub object or activity.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate ownership - ensure submitted actor matches authenticated user.
|
||||
$ownership_validation = $this->validate_ownership( $data, $user );
|
||||
if ( \is_wp_error( $ownership_validation ) ) {
|
||||
return $ownership_validation;
|
||||
}
|
||||
|
||||
// Determine if this is an Activity or a bare Object.
|
||||
$type = $data['type'] ?? '';
|
||||
$is_activity = in_array( $type, Activity::TYPES, true );
|
||||
|
||||
// If it's a bare object, wrap it in a Create activity.
|
||||
if ( ! $is_activity ) {
|
||||
$data = $this->wrap_in_create( $data, $user );
|
||||
}
|
||||
|
||||
// Resolve language maps (summaryMap, contentMap, nameMap) to plain strings.
|
||||
$data = $this->localize_language_maps( $data );
|
||||
|
||||
// Default to public addressing if client omits recipients.
|
||||
$data = $this->ensure_addressing( $data, $user );
|
||||
|
||||
// Determine visibility from addressing.
|
||||
$visibility = $this->determine_visibility( $data );
|
||||
|
||||
$type = \strtolower( $data['type'] ?? 'create' );
|
||||
|
||||
// Validate type against known activity types to prevent hook name pollution.
|
||||
$allowed_types = \array_map( 'strtolower', Activity::TYPES );
|
||||
if ( ! \in_array( $type, $allowed_types, true ) ) {
|
||||
$type = 'create';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the activity to add to outbox.
|
||||
*
|
||||
* Handlers can process the activity and return:
|
||||
* - WP_Post: A WordPress post was created (scheduler adds to outbox)
|
||||
* - int: An outbox post ID (activity already added to outbox)
|
||||
* - WP_Error: Stop processing and return error
|
||||
* - false: Stop processing (activity not allowed)
|
||||
* - array: Modified activity data (fallback to default handling)
|
||||
* - Other: No handler processed the activity (fallback to default)
|
||||
*
|
||||
* @param array $data The activity data.
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $visibility Content visibility.
|
||||
*/
|
||||
$result = \apply_filters( 'activitypub_outbox_' . $type, $data, $user_id, $visibility );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handler returned false to signal "not allowed" or "stop processing".
|
||||
if ( false === $result ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_activity_not_allowed',
|
||||
\__( 'This activity type is not allowed.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$object_id = get_object_id( $result );
|
||||
|
||||
if ( $object_id ) {
|
||||
// Handler returned a WP_Post or WP_Comment; look up its outbox entry.
|
||||
$activity_type = \ucfirst( $data['type'] ?? 'Create' );
|
||||
$outbox_item = Outbox::get_by_object_id( $object_id, $activity_type );
|
||||
} elseif ( \is_int( $result ) && $result > 0 ) {
|
||||
// Handler returned an outbox post ID directly.
|
||||
$outbox_item = \get_post( $result );
|
||||
} else {
|
||||
// Default handling for raw activities.
|
||||
$data = \is_array( $result ) ? $result : $data;
|
||||
$data = $this->ensure_object_id( $data, $user );
|
||||
$outbox_item = \get_post( add_to_outbox( $data, null, $user_id, $visibility ) );
|
||||
}
|
||||
|
||||
if ( ! $outbox_item ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_outbox_error',
|
||||
\__( 'Failed to add activity to outbox.', 'activitypub' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Get the stored activity.
|
||||
$activity = Outbox::get_activity( $outbox_item );
|
||||
|
||||
if ( \is_wp_error( $activity ) ) {
|
||||
return $activity;
|
||||
}
|
||||
|
||||
$result = $activity->to_array( false );
|
||||
|
||||
// Return 201 Created with Location header.
|
||||
$response = new \WP_REST_Response( $result, 201 );
|
||||
$response->header( 'Location', $result['id'] ?? $outbox_item->guid );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bare object in a Create activity.
|
||||
*
|
||||
* @param array $object_data The object data.
|
||||
* @param mixed $user The user/actor.
|
||||
* @return array The wrapped Create activity.
|
||||
*/
|
||||
private function wrap_in_create( $object_data, $user ) {
|
||||
// Copy addressing from object to activity.
|
||||
$addressing = array();
|
||||
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) {
|
||||
if ( ! empty( $object_data[ $field ] ) ) {
|
||||
$addressing[ $field ] = $object_data[ $field ];
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'type' => 'Create',
|
||||
'actor' => $user->get_id(),
|
||||
'object' => $object_data,
|
||||
),
|
||||
$addressing
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that activity actor matches the authenticated user.
|
||||
*
|
||||
* Ensures clients cannot submit activities with mismatched actor data.
|
||||
*
|
||||
* @param array $data The activity or object data.
|
||||
* @param \Activitypub\Model\User|null $user The authenticated user.
|
||||
* @return true|\WP_Error True if valid, WP_Error otherwise.
|
||||
*/
|
||||
private function validate_ownership( $data, $user ) {
|
||||
if ( ! $user ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_user',
|
||||
\__( 'Invalid user.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$user_actor_id = $user->get_id();
|
||||
|
||||
// Check activity actor if present.
|
||||
if ( ! empty( $data['actor'] ) ) {
|
||||
$actor_id = object_to_uri( $data['actor'] );
|
||||
if ( $actor_id && $actor_id !== $user_actor_id ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_actor_mismatch',
|
||||
\__( 'Activity actor does not match authenticated user.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check object.attributedTo if present.
|
||||
$object = $data['object'] ?? $data;
|
||||
if ( is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
|
||||
$attributed_to = object_to_uri( $object['attributedTo'] );
|
||||
if ( $attributed_to && $attributed_to !== $user_actor_id ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_attribution_mismatch',
|
||||
\__( 'Object attributedTo does not match authenticated user.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add default public addressing when the client omits recipients.
|
||||
*
|
||||
* Per the ActivityPub spec, the server adds addressing when the client
|
||||
* does not provide it. Defaults to public with followers in cc.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param array $data The activity data.
|
||||
* @param \Activitypub\Activity\Actor $user The authenticated user.
|
||||
* @return array The activity data with addressing ensured.
|
||||
*/
|
||||
private function ensure_addressing( $data, $user ) {
|
||||
$recipients = extract_recipients_from_activity( $data );
|
||||
|
||||
if ( ! empty( $recipients ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$data['to'] = array( 'https://www.w3.org/ns/activitystreams#Public' );
|
||||
$data['cc'] = array( $user->get_followers() );
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine content visibility from activity addressing.
|
||||
*
|
||||
* @param array $activity The activity data.
|
||||
* @return string Visibility constant.
|
||||
*/
|
||||
private function determine_visibility( $activity ) {
|
||||
$public = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
$to = (array) ( $activity['to'] ?? array() );
|
||||
$cc = (array) ( $activity['cc'] ?? array() );
|
||||
|
||||
// Check if public.
|
||||
if ( in_array( $public, $to, true ) ) {
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
|
||||
}
|
||||
|
||||
// Check if unlisted (public in cc).
|
||||
if ( in_array( $public, $cc, true ) ) {
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
|
||||
}
|
||||
|
||||
// Private (no public addressing).
|
||||
return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the activity object has required fields.
|
||||
*
|
||||
* For C2S activities, clients may not provide all required fields.
|
||||
* The server should fill in attributedTo and published, but object IDs
|
||||
* should only be set by handlers that create WordPress content.
|
||||
*
|
||||
* @param array $data The activity data.
|
||||
* @param \Activitypub\Model\User|null $user The authenticated user.
|
||||
* @return array The activity data with required fields ensured.
|
||||
*/
|
||||
private function ensure_object_id( $data, $user ) {
|
||||
// Check if there's an embedded object that needs fields.
|
||||
if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$object = &$data['object'];
|
||||
|
||||
// Set attributedTo if missing.
|
||||
if ( empty( $object['attributedTo'] ) && $user ) {
|
||||
$object['attributedTo'] = $user->get_id();
|
||||
}
|
||||
|
||||
// Set published if missing.
|
||||
if ( empty( $object['published'] ) ) {
|
||||
$object['published'] = \gmdate( 'Y-m-d\TH:i:s\Z' );
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
<?php
|
||||
/**
|
||||
* ActivityPub Post REST Endpoints
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Replies;
|
||||
use Activitypub\Comment;
|
||||
use Activitypub\Sanitize;
|
||||
use Activitypub\Webfinger;
|
||||
|
||||
use function Activitypub\get_post_id;
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* Class Post_Controller
|
||||
*
|
||||
* @package Activitypub\Rest
|
||||
*/
|
||||
class Post_Controller extends \WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'posts/(?P<id>[\d]+)';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/reactions',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'validate_callback' => 'Activitypub\is_post_publicly_queryable',
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_reactions' ),
|
||||
'permission_callback' => '__return_true',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/context',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'validate_callback' => 'Activitypub\is_post_publicly_queryable',
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_context' ),
|
||||
'permission_callback' => '__return_true',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/remote-intent',
|
||||
array(
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'Unique identifier for the post.',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'required' => true,
|
||||
'validate_callback' => 'Activitypub\is_post_publicly_queryable',
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_remote_intent_template' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'resource' => array(
|
||||
'description' => 'The Fediverse profile handle or URL.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => array( Sanitize::class, 'webfinger' ),
|
||||
),
|
||||
'intent' => array(
|
||||
'description' => 'The intent type.',
|
||||
'type' => 'string',
|
||||
'default' => 'like',
|
||||
'enum' => array( 'like', 'announce', 'create' ),
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reactions for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_reactions( $request ) {
|
||||
$post_id = $request->get_param( 'id' );
|
||||
|
||||
$reactions = array();
|
||||
|
||||
foreach ( Comment::get_comment_types() as $type_object ) {
|
||||
$comments = \get_comments(
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'type' => $type_object['type'],
|
||||
'status' => 'approve',
|
||||
'parent' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $comments ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = \count( $comments );
|
||||
// phpcs:disable WordPress.WP.I18n
|
||||
$label = \sprintf(
|
||||
\_n(
|
||||
$type_object['count_single'],
|
||||
$type_object['count_plural'],
|
||||
$count,
|
||||
'activitypub'
|
||||
),
|
||||
\number_format_i18n( $count )
|
||||
);
|
||||
// phpcs:enable WordPress.WP.I18n
|
||||
|
||||
$reactions[ $type_object['collection'] ] = array(
|
||||
'label' => $label,
|
||||
'items' => \array_map(
|
||||
static function ( $comment ) {
|
||||
/*
|
||||
* Decode entities first so a stored pseudo-tag like
|
||||
* `<img>` becomes a real `<img>` for the next
|
||||
* step to remove, then strip any tags so the JSON
|
||||
* response contains only plain text. `esc_url()`
|
||||
* rejects `javascript:` and other unsafe schemes.
|
||||
*/
|
||||
return array(
|
||||
'name' => \wp_strip_all_tags( \html_entity_decode( $comment->comment_author, ENT_QUOTES ) ),
|
||||
'url' => \esc_url( $comment->comment_author_url ),
|
||||
'avatar' => \esc_url( \get_avatar_url( $comment ) ),
|
||||
);
|
||||
},
|
||||
$comments
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( $reactions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_context( $request ) {
|
||||
$post_id = $request->get_param( 'id' );
|
||||
$collection = Replies::get_context_collection( $post_id );
|
||||
|
||||
if ( false === $collection ) {
|
||||
return new \WP_Error( 'activitypub_post_not_found', \__( 'Post not found', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
$response = array_merge(
|
||||
array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( \sprintf( 'posts/%d/context', $post_id ) ),
|
||||
),
|
||||
$collection
|
||||
);
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote intent template for a post.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public function get_remote_intent_template( $request ) {
|
||||
$post_id = $request->get_param( 'id' );
|
||||
$resource = $request->get_param( 'resource' );
|
||||
$intent = $request->get_param( 'intent' );
|
||||
$post = \get_post( $post_id );
|
||||
|
||||
$template = Webfinger::get_intent_endpoint( $resource, $intent, true );
|
||||
|
||||
if ( \is_wp_error( $template ) ) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$id = get_post_id( $post_id );
|
||||
|
||||
$url = \str_replace(
|
||||
array(
|
||||
'{object}',
|
||||
'{uri}',
|
||||
'{inReplyTo}',
|
||||
'{name}',
|
||||
'{target}',
|
||||
),
|
||||
array(
|
||||
\rawurlencode( $id ),
|
||||
\rawurlencode( $id ),
|
||||
\rawurlencode( $id ),
|
||||
\rawurlencode( $post->post_title ),
|
||||
\rawurlencode( $resource ),
|
||||
),
|
||||
$template
|
||||
);
|
||||
|
||||
// Remove any other GET-Params with placeholders to avoid confusion.
|
||||
$url = \preg_replace( '/([&?][^=]+=\{[^}]+\})/', '', $url );
|
||||
|
||||
return \rest_ensure_response(
|
||||
array(
|
||||
'url' => $url,
|
||||
'template' => $template,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,160 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ActivityPub Post REST Endpoints
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use WP_REST_Server;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use Activitypub\Comment;
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Replies;
|
||||
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
|
||||
/**
|
||||
* Class Post
|
||||
*
|
||||
* @package Activitypub\Rest
|
||||
*/
|
||||
class Post {
|
||||
|
||||
/**
|
||||
* Initialize the class and register routes.
|
||||
*/
|
||||
public static function init() {
|
||||
self::register_routes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public static function register_routes() {
|
||||
register_rest_route(
|
||||
ACTIVITYPUB_REST_NAMESPACE,
|
||||
'/posts/(?P<id>\d+)/reactions',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( static::class, 'get_reactions' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
ACTIVITYPUB_REST_NAMESPACE,
|
||||
'/posts/(?P<id>\d+)/context',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( static::class, 'get_context' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reactions for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public static function get_reactions( $request ) {
|
||||
$post_id = $request->get_param( 'id' );
|
||||
$post = get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
$reactions = array();
|
||||
|
||||
foreach ( Comment::get_comment_types() as $type_object ) {
|
||||
$comments = get_comments(
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'type' => $type_object['type'],
|
||||
'status' => 'approve',
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $comments ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = count( $comments );
|
||||
// phpcs:disable WordPress.WP.I18n
|
||||
$label = sprintf(
|
||||
_n(
|
||||
$type_object['count_single'],
|
||||
$type_object['count_plural'],
|
||||
$count,
|
||||
'activitypub'
|
||||
),
|
||||
number_format_i18n( $count )
|
||||
);
|
||||
// phpcs:enable WordPress.WP.I18n
|
||||
|
||||
$reactions[ $type_object['collection'] ] = array(
|
||||
'label' => $label,
|
||||
'items' => array_map(
|
||||
function ( $comment ) {
|
||||
return array(
|
||||
'name' => $comment->comment_author,
|
||||
'url' => $comment->comment_author_url,
|
||||
'avatar' => get_comment_meta( $comment->comment_ID, 'avatar_url', true ),
|
||||
);
|
||||
},
|
||||
$comments
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response( $reactions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context for a post.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
|
||||
*/
|
||||
public static function get_context( $request ) {
|
||||
$post_id = $request->get_param( 'id' );
|
||||
|
||||
$collection = Replies::get_context_collection( $post_id );
|
||||
|
||||
if ( false === $collection ) {
|
||||
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
$response = array_merge(
|
||||
array(
|
||||
'@context' => Base_Object::JSON_LD_CONTEXT,
|
||||
'id' => get_rest_url_by_path( sprintf( 'posts/%d/context', $post_id ) ),
|
||||
),
|
||||
$collection
|
||||
);
|
||||
|
||||
$response = \rest_ensure_response( $response );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
<?php
|
||||
/**
|
||||
* Proxy Controller file.
|
||||
*
|
||||
* Implements the proxyUrl endpoint for C2S clients to fetch remote ActivityPub objects.
|
||||
*
|
||||
* @package Activitypub
|
||||
* @see https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Http;
|
||||
use Activitypub\Webfinger;
|
||||
|
||||
use function Activitypub\is_actor;
|
||||
|
||||
/**
|
||||
* Proxy Controller.
|
||||
*
|
||||
* Provides a bridge between C2S OAuth authentication and S2S HTTP Signature authentication.
|
||||
* Allows C2S clients to fetch remote ActivityPub objects through their home server.
|
||||
*/
|
||||
class Proxy_Controller extends \WP_REST_Controller {
|
||||
use Event_Stream;
|
||||
use Verification;
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'proxy';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'create_item' ),
|
||||
'permission_callback' => array( $this, 'verify_authentication' ),
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The remote ActivityPub object to fetch: an HTTPS URL or an acct identifier (`user@host`, `@user@host`, or `acct:user@host`).',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => array( $this, 'sanitize_url' ),
|
||||
'validate_callback' => array( $this, 'validate_url' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/stream',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_stream' ),
|
||||
'permission_callback' => array( $this, 'get_stream_permissions_check' ),
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
'description' => 'The remote actor identifier (URL or WebFinger acct) whose eventStream to proxy.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => array( $this, 'sanitize_url' ),
|
||||
'validate_callback' => array( $this, 'validate_url' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the `id` parameter.
|
||||
*
|
||||
* Accepts either an HTTPS URL or an acct identifier (`user@host`,
|
||||
* `@user@host`, or `acct:user@host`). Acct identifiers are returned
|
||||
* as-is; URLs are run through `sanitize_url()`. Matches the dual-shape
|
||||
* contract of `Remote_Actors::fetch_by_various()`.
|
||||
*
|
||||
* @see https://developer.wordpress.org/reference/functions/sanitize_url/
|
||||
*
|
||||
* @param string $url The urlencoded URL or acct identifier to sanitize.
|
||||
* @return string The sanitized value.
|
||||
*/
|
||||
public function sanitize_url( $url ) {
|
||||
$decoded = \urldecode( $url );
|
||||
|
||||
if ( Webfinger::is_acct( $decoded ) ) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
return \sanitize_url( $decoded );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the `id` parameter.
|
||||
*
|
||||
* Accepts either an HTTPS URL (validated via `wp_http_validate_url()`,
|
||||
* which blocks local/private IPs and restricts ports) or an acct
|
||||
* identifier in any of the forms accepted by `Webfinger::is_acct()`:
|
||||
* `user@host`, `@user@host`, or `acct:user@host`. Matches the
|
||||
* dual-shape contract of `Remote_Actors::fetch_by_various()`.
|
||||
*
|
||||
* @see https://developer.wordpress.org/reference/functions/wp_http_validate_url/
|
||||
*
|
||||
* @param string $url The URL or acct identifier to validate.
|
||||
* @return bool True if valid, false otherwise.
|
||||
*/
|
||||
public function validate_url( $url ) {
|
||||
$decoded_url = \urldecode( $url );
|
||||
|
||||
if ( Webfinger::is_acct( $decoded_url ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Must be HTTPS.
|
||||
if ( 'https' !== \wp_parse_url( $decoded_url, PHP_URL_SCHEME ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use WordPress built-in validation (blocks local IPs, restricts ports).
|
||||
return (bool) \wp_http_validate_url( $decoded_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote ActivityPub object via the proxy.
|
||||
*
|
||||
* @see https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
* @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure.
|
||||
*/
|
||||
public function create_item( $request ) {
|
||||
// Rate-limit proxy requests (max 30 per minute per user).
|
||||
$user_id = \get_current_user_id();
|
||||
$transient_key = 'ap_proxy_' . $user_id;
|
||||
$count = (int) \get_transient( $transient_key );
|
||||
|
||||
if ( $count >= 30 ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_rate_limit',
|
||||
\__( 'Too many proxy requests. Please try again later.', 'activitypub' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
|
||||
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$url = $request->get_param( 'id' );
|
||||
|
||||
// Try to fetch as an actor first using Remote_Actors which handles caching.
|
||||
$post = Remote_Actors::fetch_by_various( $url );
|
||||
|
||||
if ( ! \is_wp_error( $post ) ) {
|
||||
$actor = Remote_Actors::get_actor( $post );
|
||||
|
||||
if ( ! \is_wp_error( $actor ) ) {
|
||||
$response = new \WP_REST_Response( $actor->to_array(), 200 );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fetching as a generic object.
|
||||
$object = Http::get_remote_object( $url );
|
||||
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_fetch_failed',
|
||||
\__( 'Failed to fetch the remote object.', 'activitypub' ),
|
||||
array( 'status' => 502 )
|
||||
);
|
||||
}
|
||||
|
||||
// If it's an actor, store it for future use.
|
||||
if ( is_actor( $object ) ) {
|
||||
Remote_Actors::upsert( $object );
|
||||
}
|
||||
|
||||
$response = new \WP_REST_Response( $object, 200 );
|
||||
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the schema for the proxy endpoint.
|
||||
*
|
||||
* @return array Schema array.
|
||||
*/
|
||||
public function get_item_schema() {
|
||||
return array(
|
||||
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
||||
'title' => 'proxy',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'id' => array(
|
||||
'description' => \__( 'The URI of the remote ActivityPub object.', 'activitypub' ),
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'context' => array( 'view' ),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a remote eventStream.
|
||||
*
|
||||
* Fetches the remote object to discover its eventStream URL,
|
||||
* then opens a streaming connection and relays SSE events.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return \WP_Error|void WP_Error on failure, exits on success.
|
||||
*/
|
||||
public function get_stream( $request ) {
|
||||
$remote_id = $request->get_param( 'id' );
|
||||
|
||||
$object = Http::get_remote_object( $remote_id );
|
||||
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_proxy_fetch_failed',
|
||||
\__( 'Failed to fetch the remote object.', 'activitypub' ),
|
||||
array( 'status' => 502 )
|
||||
);
|
||||
}
|
||||
|
||||
$stream_url = isset( $object['eventStream'] ) ? $object['eventStream'] : null;
|
||||
|
||||
if ( ! $stream_url ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_no_event_stream',
|
||||
\__( 'The remote object does not advertise an eventStream.', 'activitypub' ),
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! $this->validate_url( $stream_url ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_event_stream',
|
||||
\__( 'The remote eventStream URL is not valid.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$this->relay_remote_stream( $stream_url );
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,11 @@
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Activity\Base_Object;
|
||||
use Activitypub\Collection\Replies;
|
||||
use Activitypub\Collection\Interactions;
|
||||
use Activitypub\Collection\Replies;
|
||||
|
||||
use function Activitypub\get_rest_url_by_path;
|
||||
use function Activitypub\is_post_publicly_queryable;
|
||||
|
||||
/**
|
||||
* ActivityPub Replies_Controller class.
|
||||
@ -30,7 +31,7 @@ class Replies_Controller extends \WP_REST_Controller {
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = '(?P<object_type>[\w\-\.]+)s/(?P<id>[\w\-\.]+)/(?P<type>[\w\-\.]+)';
|
||||
protected $rest_base = '(?P<object_type>[\w\-\.]+)s/(?P<id>[\d]+)/(?P<type>[\w\-\.]+)';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
@ -49,7 +50,8 @@ class Replies_Controller extends \WP_REST_Controller {
|
||||
),
|
||||
'id' => array(
|
||||
'description' => 'The ID of the object.',
|
||||
'type' => 'string',
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'required' => true,
|
||||
),
|
||||
'type' => array(
|
||||
@ -90,11 +92,22 @@ class Replies_Controller extends \WP_REST_Controller {
|
||||
|
||||
if ( 'comment' === $object_type ) {
|
||||
$wp_object = \get_comment( $id );
|
||||
|
||||
/*
|
||||
* A comment inherits the visibility of its parent post. Reject
|
||||
* collections for comments whose parent post is not currently
|
||||
* publicly queryable, so we don't leak metadata for private,
|
||||
* draft, password-protected, or local-only posts — including
|
||||
* posts that were once federated and have since been made
|
||||
* non-public.
|
||||
*/
|
||||
$publicly_queryable = $wp_object && is_post_publicly_queryable( $wp_object->comment_post_ID );
|
||||
} else {
|
||||
$wp_object = \get_post( $id );
|
||||
$wp_object = \get_post( $id );
|
||||
$publicly_queryable = $wp_object && is_post_publicly_queryable( $wp_object );
|
||||
}
|
||||
|
||||
if ( ! isset( $wp_object ) || \is_wp_error( $wp_object ) ) {
|
||||
if ( ! $publicly_queryable ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_replies_collection_does_not_exist',
|
||||
\sprintf(
|
||||
@ -167,13 +180,11 @@ class Replies_Controller extends \WP_REST_Controller {
|
||||
$likes = 0;
|
||||
}
|
||||
|
||||
$response = array(
|
||||
return array(
|
||||
'id' => get_rest_url_by_path( sprintf( 'posts/%d/likes', $wp_object->ID ) ),
|
||||
'type' => 'Collection',
|
||||
'totalItems' => $likes,
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,18 +197,16 @@ class Replies_Controller extends \WP_REST_Controller {
|
||||
*/
|
||||
public function get_shares( $request, $wp_object ) {
|
||||
if ( $wp_object instanceof \WP_Post ) {
|
||||
$shares = Interactions::count_by_type( $wp_object->ID, 'repost' );
|
||||
$shares = Interactions::count_by_type( $wp_object->ID, 'repost' ) + Interactions::count_by_type( $wp_object->ID, 'quote' );
|
||||
} else {
|
||||
$shares = 0;
|
||||
}
|
||||
|
||||
$response = array(
|
||||
return array(
|
||||
'id' => get_rest_url_by_path( sprintf( 'posts/%d/shares', $wp_object->ID ) ),
|
||||
'type' => 'Collection',
|
||||
'totalItems' => $shares,
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,13 +7,6 @@
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use WP_Error;
|
||||
use WP_REST_Server;
|
||||
use WP_REST_Response;
|
||||
use Activitypub\Signature;
|
||||
|
||||
use function Activitypub\use_authorized_fetch;
|
||||
|
||||
/**
|
||||
* ActivityPub Server REST-Class.
|
||||
*
|
||||
@ -26,83 +19,23 @@ class Server {
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
self::add_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sever hooks.
|
||||
*/
|
||||
public static function add_hooks() {
|
||||
\add_filter( 'rest_request_before_callbacks', array( self::class, 'validate_requests' ), 9, 3 );
|
||||
\add_filter( 'rest_request_parameter_order', array( self::class, 'request_parameter_order' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function to authorize an api request.
|
||||
*
|
||||
* The function is meant to be used as part of permission callbacks for rest api endpoints.
|
||||
*
|
||||
* It verifies the signature of POST, PUT, PATCH, and DELETE requests, as well as GET requests in secure mode.
|
||||
* You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification.
|
||||
* HEAD requests are always bypassed.
|
||||
*
|
||||
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
|
||||
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return bool|\WP_Error True if the request is authorized, WP_Error if not.
|
||||
*/
|
||||
public static function verify_signature( $request ) {
|
||||
if ( 'HEAD' === $request->get_method() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to defer signature verification.
|
||||
*
|
||||
* Skip signature verification for debugging purposes or to reduce load for
|
||||
* certain Activity-Types, like "Delete".
|
||||
*
|
||||
* @param bool $defer Whether to defer signature verification.
|
||||
* @param \WP_REST_Request $request The request used to generate the response.
|
||||
*
|
||||
* @return bool Whether to defer signature verification.
|
||||
*/
|
||||
$defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
|
||||
|
||||
if ( $defer ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
// POST-Requests always have to be signed.
|
||||
'GET' !== $request->get_method() ||
|
||||
// GET-Requests only require a signature in secure mode.
|
||||
( 'GET' === $request->get_method() && use_authorized_fetch() )
|
||||
) {
|
||||
$verified_request = Signature::verify_http_signature( $request );
|
||||
if ( \is_wp_error( $verified_request ) ) {
|
||||
return new WP_Error(
|
||||
'activitypub_signature_verification',
|
||||
$verified_request->get_error_message(),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
\add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 );
|
||||
\add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 );
|
||||
\add_filter( 'rest_allowed_cors_headers', array( self::class, 'allow_cors_headers' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function to validate incoming ActivityPub requests
|
||||
*
|
||||
* @param WP_REST_Response|\WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
|
||||
* Usually a WP_REST_Response or WP_Error.
|
||||
* @param array $handler Route handler used for the request.
|
||||
* @param \WP_REST_Request $request Request used to generate the response.
|
||||
* @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Result to send to the client.
|
||||
* Usually a WP_REST_Response or WP_Error.
|
||||
* @param array $handler Route handler used for the request.
|
||||
* @param \WP_REST_Request $request Request used to generate the response.
|
||||
*
|
||||
* @return mixed|WP_Error The response, error, or modified response.
|
||||
* @return mixed|\WP_Error The response, error, or modified response.
|
||||
*/
|
||||
public static function validate_requests( $response, $handler, $request ) {
|
||||
if ( 'HEAD' === $request->get_method() ) {
|
||||
@ -129,7 +62,7 @@ class Server {
|
||||
ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS &&
|
||||
in_array( $params['type'], array( 'Create', 'Like', 'Announce' ), true )
|
||||
) {
|
||||
return new WP_Error(
|
||||
return new \WP_Error(
|
||||
'activitypub_server_does_not_accept_incoming_interactions',
|
||||
\__( 'This server does not accept incoming interactions.', 'activitypub' ),
|
||||
// We have to use a 2XX status code here, because otherwise the response will be
|
||||
@ -159,7 +92,7 @@ class Server {
|
||||
|
||||
$method = $request->get_method();
|
||||
|
||||
if ( WP_REST_Server::CREATABLE !== $method ) {
|
||||
if ( \WP_REST_Server::CREATABLE !== $method ) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
@ -170,4 +103,136 @@ class Server {
|
||||
'defaults',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the REST API response to properly handle the ActivityPub error formatting.
|
||||
*
|
||||
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/c180/fep-c180.md
|
||||
*
|
||||
* @param \WP_HTTP_Response $response Result to send to the client. Usually a `WP_REST_Response`.
|
||||
* @param \WP_REST_Server $server Server instance.
|
||||
* @param \WP_REST_Request $request Request used to generate the response.
|
||||
*
|
||||
* @return \WP_HTTP_Response The filtered response.
|
||||
*/
|
||||
public static function filter_output( $response, $server, $request ) {
|
||||
$route = $request->get_route();
|
||||
|
||||
// Check if it is an activitypub request and exclude webfinger and nodeinfo endpoints.
|
||||
if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Exclude OAuth endpoints - they have their own error format per RFC 6749.
|
||||
if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Only alter responses that return an error status code.
|
||||
if ( $response->get_status() < 400 ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$data = $response->get_data();
|
||||
|
||||
// Ensure that `$data` was already converted to a response.
|
||||
if ( \is_wp_error( $data ) ) {
|
||||
$response = \rest_convert_error_to_response( $data );
|
||||
$data = $response->get_data();
|
||||
}
|
||||
|
||||
$error = array(
|
||||
'type' => 'about:blank',
|
||||
'title' => $data['code'] ?? '',
|
||||
'detail' => $data['message'] ?? '',
|
||||
'status' => $response->get_status(),
|
||||
|
||||
/*
|
||||
* Provides the unstructured error data.
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html#metadata.
|
||||
*/
|
||||
'metadata' => $data,
|
||||
);
|
||||
|
||||
$response->set_data( $error );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to ActivityPub REST responses.
|
||||
*
|
||||
* @param \WP_REST_Response $response The REST response.
|
||||
* @param \WP_REST_Server $server The REST server instance.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return \WP_REST_Response The modified response.
|
||||
*/
|
||||
public static function add_cors_headers( $response, $server, $request ) {
|
||||
$route = $request->get_route();
|
||||
$namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
// Only add CORS to ActivityPub endpoints, except the interactive OAuth authorize endpoint.
|
||||
if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/*
|
||||
* ActivityPub data is meant to be publicly readable by federation peers
|
||||
* and browser-side clients. We do not enable credentialed cross-origin
|
||||
* access: cookie auth would still be rejected by WordPress core's
|
||||
* REST nonce check, and OAuth Bearer tokens travel in the
|
||||
* Authorization header — which is permitted via Allow-Headers and
|
||||
* does not require Allow-Credentials.
|
||||
*
|
||||
* Allow-Headers is contributed by core (which already lists `X-WP-Nonce`,
|
||||
* `Authorization`, `Content-Type`, `Content-Disposition`, and `Content-MD5`)
|
||||
* and extended for ActivityPub via the `rest_allowed_cors_headers` filter
|
||||
* in self::allow_cors_headers().
|
||||
*/
|
||||
$response->header( 'Access-Control-Allow-Origin', '*' );
|
||||
$response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' );
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the CORS Allow-Headers list for ActivityPub REST endpoints.
|
||||
*
|
||||
* Adds the headers ActivityPub clients need on top of WordPress core's
|
||||
* defaults: `Accept` for content negotiation and `Last-Event-ID` for
|
||||
* Server-Sent Events resume.
|
||||
*
|
||||
* @since 8.3.0
|
||||
*
|
||||
* @param string[] $allow_headers Headers core currently permits in CORS requests.
|
||||
* @param \WP_REST_Request $request The current REST request.
|
||||
*
|
||||
* @return string[] The (possibly extended) list of allowed headers.
|
||||
*/
|
||||
public static function allow_cors_headers( $allow_headers, $request ) {
|
||||
$route = $request->get_route();
|
||||
$namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
if ( ! \str_starts_with( $route, $namespace ) || \str_starts_with( $route, $namespace . '/oauth/authorize' ) ) {
|
||||
return $allow_headers;
|
||||
}
|
||||
|
||||
return \array_values( \array_unique( \array_merge( (array) $allow_headers, array( 'Accept', 'Last-Event-ID' ) ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CORS headers directly via header().
|
||||
*
|
||||
* Use this for endpoints that bypass the REST response flow
|
||||
* (e.g. SSE streams that call exit() instead of returning a WP_REST_Response).
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
public static function send_cors_headers() {
|
||||
\header( 'Access-Control-Allow-Origin: *' );
|
||||
\header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
|
||||
\header( 'Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type, Accept, Last-Event-ID' );
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
/**
|
||||
* Stats_Image_Controller file.
|
||||
*
|
||||
* @package Activitypub
|
||||
* @since 8.1.0
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Cache\Stats_Image;
|
||||
|
||||
/**
|
||||
* REST controller that serves stats share images.
|
||||
*
|
||||
* Provides two endpoints:
|
||||
* - /stats/image/{user_id}/{year} — serves the image binary
|
||||
* - /stats/image-url/{user_id}/{year} — returns the image URL as JSON
|
||||
*/
|
||||
class Stats_Image_Controller extends \WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'stats/image';
|
||||
|
||||
/**
|
||||
* Common route args for user_id and year.
|
||||
*
|
||||
* @return array The route args.
|
||||
*/
|
||||
private function get_common_args() {
|
||||
return array(
|
||||
'user_id' => array(
|
||||
'description' => \__( 'The user ID to generate the stats image for.', 'activitypub' ),
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
'validate_callback' => array( $this, 'validate_user_id' ),
|
||||
),
|
||||
'year' => array(
|
||||
'description' => \__( 'The year to display stats for.', 'activitypub' ),
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
'minimum' => 2000,
|
||||
'maximum' => (int) \gmdate( 'Y' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user_id parameter.
|
||||
*
|
||||
* @param mixed $value The parameter value.
|
||||
*
|
||||
* @return true|\WP_Error True if valid, error otherwise.
|
||||
*/
|
||||
public function validate_user_id( $value ) {
|
||||
$user_id = (int) $value;
|
||||
|
||||
// Blog user ID (0) is always valid.
|
||||
if ( 0 === $user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check that the user exists.
|
||||
if ( ! \get_user_by( 'id', $user_id ) ) {
|
||||
return new \WP_Error( 'invalid_user', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
$route_pattern = '/(?P<user_id>[\d]+)/(?P<year>[\d]{4})';
|
||||
|
||||
// Serve the image binary.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . $route_pattern,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => $this->get_common_args(),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Return the image URL as JSON.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '-url' . $route_pattern,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_url' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => $this->get_common_args(),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the stats image binary.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return void|\WP_Error Streams image and exits, or returns error.
|
||||
*/
|
||||
public function get_item( $request ) {
|
||||
return Stats_Image::serve(
|
||||
(int) $request->get_param( 'user_id' ),
|
||||
(int) $request->get_param( 'year' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the resolved image URL as JSON.
|
||||
*
|
||||
* Returns the cached file URL if available, otherwise the REST
|
||||
* endpoint URL. Filtered via `activitypub_stats_image_url` so
|
||||
* it can be routed through a CDN or image proxy like Photon.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error JSON response with the URL.
|
||||
*/
|
||||
public function get_url( $request ) {
|
||||
$url = Stats_Image::get_url(
|
||||
(int) $request->get_param( 'user_id' ),
|
||||
(int) $request->get_param( 'year' )
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $url ) ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return \rest_ensure_response( array( 'url' => $url ) );
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ActivityPub URL Validator Controller.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Http;
|
||||
use Activitypub\Embed;
|
||||
|
||||
/**
|
||||
* URL Validator Controller Class.
|
||||
*/
|
||||
class URL_Validator_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'url/validate';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_items' ),
|
||||
'permission_callback' => array( $this, 'get_items_permissions_check' ),
|
||||
'args' => array(
|
||||
'url' => array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
),
|
||||
'schema' => array( $this, 'get_item_schema' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given request has access to validate URLs.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return bool True if the request has access to validate URLs, false otherwise.
|
||||
*/
|
||||
public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
return current_user_can( 'edit_posts' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is a valid ActivityPub endpoint.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request.
|
||||
*
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function get_items( $request ) {
|
||||
$url = $request->get_param( 'url' );
|
||||
$object = Http::get_remote_object( $url );
|
||||
|
||||
if ( is_wp_error( $object ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_url',
|
||||
__( 'Invalid URL.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$response = array(
|
||||
'is_activitypub' => ! empty( $object['type'] ),
|
||||
'is_real_oembed' => Embed::has_real_oembed( $url ),
|
||||
'html' => false,
|
||||
);
|
||||
|
||||
if ( $response['is_activitypub'] ) {
|
||||
$response['html'] = wp_oembed_get( $url );
|
||||
}
|
||||
|
||||
return rest_ensure_response( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL validation schema.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_item_schema() {
|
||||
if ( $this->schema ) {
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
|
||||
$schema = array(
|
||||
'$schema' => 'http://json-schema.org/draft-04/schema#',
|
||||
'title' => 'validated-url',
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'is_activitypub' => array(
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
),
|
||||
'is_real_oembed' => array(
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
),
|
||||
'html' => array(
|
||||
'type' => 'string',
|
||||
'default' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$this->schema = $schema;
|
||||
|
||||
return $this->add_additional_fields_schema( $this->schema );
|
||||
}
|
||||
}
|
||||
@ -85,8 +85,7 @@ class Webfinger_Controller extends \WP_REST_Controller {
|
||||
$response,
|
||||
$code,
|
||||
array(
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ),
|
||||
'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,406 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Authorization REST Controller.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest\OAuth;
|
||||
|
||||
use Activitypub\OAuth\Authorization_Code;
|
||||
use Activitypub\OAuth\Client;
|
||||
use Activitypub\OAuth\Scope;
|
||||
|
||||
use function Activitypub\get_client_ip;
|
||||
|
||||
/**
|
||||
* Authorization_Controller class for handling the OAuth 2.0 authorization endpoint.
|
||||
*
|
||||
* Implements:
|
||||
* - Authorization endpoint (GET/POST /oauth/authorize)
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
class Authorization_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'oauth';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
// Authorization endpoint - GET displays consent form, POST handles approval.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/authorize',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'authorize' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'response_type' => array(
|
||||
'description' => 'OAuth response type (must be "code").',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => array( 'code' ),
|
||||
),
|
||||
'client_id' => array(
|
||||
'description' => 'The OAuth client identifier.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'redirect_uri' => array(
|
||||
'description' => 'The URI to redirect to after authorization. Supports custom URI schemes for native apps.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'scope' => array(
|
||||
'description' => 'Space-separated list of requested scopes.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'state' => array(
|
||||
'description' => 'Opaque value for CSRF protection.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code_challenge' => array(
|
||||
'description' => 'PKCE code challenge (recommended).',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code_challenge_method' => array(
|
||||
'description' => 'PKCE code challenge method.',
|
||||
'type' => 'string',
|
||||
'enum' => array( 'S256' ),
|
||||
'default' => 'S256',
|
||||
),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'authorize_submit' ),
|
||||
'permission_callback' => array( $this, 'authorize_submit_permissions_check' ),
|
||||
'args' => array(
|
||||
'response_type' => array(
|
||||
'description' => 'OAuth response type (must be "code").',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => array( 'code' ),
|
||||
),
|
||||
'client_id' => array(
|
||||
'description' => 'The OAuth client identifier.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'redirect_uri' => array(
|
||||
'description' => 'The URI to redirect to after authorization. Supports custom URI schemes for native apps.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'scope' => array(
|
||||
'description' => 'Space-separated list of requested scopes.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'state' => array(
|
||||
'description' => 'Opaque value for CSRF protection.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code_challenge' => array(
|
||||
'description' => 'PKCE code challenge (recommended).',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code_challenge_method' => array(
|
||||
'description' => 'PKCE code challenge method.',
|
||||
'type' => 'string',
|
||||
'enum' => array( 'S256' ),
|
||||
'default' => 'S256',
|
||||
),
|
||||
'approve' => array(
|
||||
'description' => 'Whether the user approved the authorization.',
|
||||
'type' => 'boolean',
|
||||
'required' => true,
|
||||
),
|
||||
'_wpnonce' => array(
|
||||
'description' => 'WordPress nonce for CSRF protection.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authorization request (GET /oauth/authorize).
|
||||
*
|
||||
* Validates request parameters and redirects to wp-admin consent page.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function authorize( \WP_REST_Request $request ) {
|
||||
// Rate-limit authorization requests to prevent abuse (max 20 per minute per IP).
|
||||
$ip = get_client_ip();
|
||||
if ( '' === $ip ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_rate_limit',
|
||||
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
$transient_key = 'ap_oauth_auth_' . \md5( $ip );
|
||||
$count = (int) \get_transient( $transient_key );
|
||||
|
||||
if ( $count >= 20 ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_rate_limit',
|
||||
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
|
||||
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$client_id = $request->get_param( 'client_id' );
|
||||
$redirect_uri = $request->get_param( 'redirect_uri' );
|
||||
$response_type = $request->get_param( 'response_type' );
|
||||
$scope = $request->get_param( 'scope' );
|
||||
$state = $request->get_param( 'state' );
|
||||
|
||||
// Validate client.
|
||||
$client = Client::get( $client_id );
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return $this->error_page( $client );
|
||||
}
|
||||
|
||||
// Validate redirect URI.
|
||||
if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
|
||||
return $this->error_page(
|
||||
new \WP_Error(
|
||||
'activitypub_invalid_redirect_uri',
|
||||
\__( 'Invalid redirect URI for this client.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Only support 'code' response type.
|
||||
if ( 'code' !== $response_type ) {
|
||||
return $this->redirect_with_error(
|
||||
$redirect_uri,
|
||||
'unsupported_response_type',
|
||||
'Only authorization code flow is supported.',
|
||||
$state
|
||||
);
|
||||
}
|
||||
|
||||
// Check for PKCE (recommended but optional for compatibility).
|
||||
$code_challenge = $request->get_param( 'code_challenge' );
|
||||
|
||||
/*
|
||||
* Redirect to wp-login.php with action=activitypub_authorize.
|
||||
* This uses WordPress's login_form_{action} hook for proper cookie auth.
|
||||
*/
|
||||
$login_url = \wp_login_url();
|
||||
$login_url = \add_query_arg(
|
||||
array(
|
||||
'action' => 'activitypub_authorize',
|
||||
'client_id' => $client_id,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'response_type' => $response_type,
|
||||
'scope' => $scope,
|
||||
'state' => $state,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256',
|
||||
),
|
||||
$login_url
|
||||
);
|
||||
|
||||
return new \WP_REST_Response(
|
||||
null,
|
||||
302,
|
||||
array( 'Location' => $login_url )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authorization approval (POST /oauth/authorize).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function authorize_submit( \WP_REST_Request $request ) {
|
||||
$client_id = $request->get_param( 'client_id' );
|
||||
$redirect_uri = $request->get_param( 'redirect_uri' );
|
||||
$scope = $request->get_param( 'scope' );
|
||||
$state = $request->get_param( 'state' );
|
||||
$code_challenge = $request->get_param( 'code_challenge' );
|
||||
$code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256';
|
||||
$approve = $request->get_param( 'approve' );
|
||||
|
||||
// Re-validate client and redirect URI (form fields could be tampered with).
|
||||
$client = Client::get( $client_id );
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return $this->error_page( $client );
|
||||
}
|
||||
|
||||
if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
|
||||
return $this->error_page(
|
||||
new \WP_Error(
|
||||
'activitypub_invalid_redirect_uri',
|
||||
\__( 'Invalid redirect URI for this client.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// User denied authorization.
|
||||
if ( ! $approve ) {
|
||||
return $this->redirect_with_error(
|
||||
$redirect_uri,
|
||||
'access_denied',
|
||||
'The user denied the authorization request.',
|
||||
$state
|
||||
);
|
||||
}
|
||||
|
||||
// Create authorization code.
|
||||
$scopes = Scope::validate( Scope::parse( $scope ) );
|
||||
$code = Authorization_Code::create(
|
||||
\get_current_user_id(),
|
||||
$client_id,
|
||||
$redirect_uri,
|
||||
$scopes,
|
||||
$code_challenge,
|
||||
$code_challenge_method
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $code ) ) {
|
||||
return $this->redirect_with_error(
|
||||
$redirect_uri,
|
||||
'server_error',
|
||||
$code->get_error_message(),
|
||||
$state
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect back to client with code.
|
||||
$redirect_url = \add_query_arg(
|
||||
array(
|
||||
'code' => $code,
|
||||
'state' => $state,
|
||||
),
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
return new \WP_REST_Response(
|
||||
null,
|
||||
302,
|
||||
array( 'Location' => $redirect_url )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check for authorization submission.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return bool|\WP_Error True if allowed, error otherwise.
|
||||
*/
|
||||
public function authorize_submit_permissions_check( \WP_REST_Request $request ) {
|
||||
if ( ! \is_user_logged_in() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_not_logged_in',
|
||||
\__( 'You must be logged in to authorize applications.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify nonce.
|
||||
$nonce = $request->get_param( '_wpnonce' );
|
||||
if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_nonce',
|
||||
\__( 'Invalid security token. Please try again.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to wp-login.php with a styled error message.
|
||||
*
|
||||
* These errors occur before a valid redirect URI is confirmed, so we
|
||||
* cannot safely redirect back to the client. Instead, redirect to
|
||||
* wp-login.php where the error is rendered using login_header/login_footer
|
||||
* for a consistent, user-friendly appearance.
|
||||
*
|
||||
* The error message is stored in a short-lived transient (5 minutes)
|
||||
* keyed by a random token. Only the opaque token is passed in the URL,
|
||||
* preventing social-engineering attacks where an attacker crafts a URL
|
||||
* with arbitrary error text displayed inside WordPress login chrome.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_Error $error The error to display.
|
||||
* @return \WP_REST_Response Redirect response to wp-login.php.
|
||||
*/
|
||||
private function error_page( $error ) {
|
||||
$token = \wp_generate_password( 20, false );
|
||||
\set_transient( 'ap_oauth_err_' . $token, $error->get_error_message(), 5 * MINUTE_IN_SECONDS );
|
||||
|
||||
$login_url = \add_query_arg(
|
||||
array(
|
||||
'action' => 'activitypub_authorize',
|
||||
'auth_error' => $token,
|
||||
),
|
||||
\wp_login_url()
|
||||
);
|
||||
|
||||
return new \WP_REST_Response(
|
||||
null,
|
||||
302,
|
||||
array( 'Location' => $login_url )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect with an OAuth error.
|
||||
*
|
||||
* @param string $redirect_uri The redirect URI.
|
||||
* @param string $error Error code.
|
||||
* @param string $description Error description.
|
||||
* @param string $state The state parameter.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) {
|
||||
$params = array(
|
||||
'error' => $error,
|
||||
'error_description' => $description,
|
||||
);
|
||||
|
||||
if ( $state ) {
|
||||
$params['state'] = $state;
|
||||
}
|
||||
|
||||
$redirect_url = \add_query_arg( $params, $redirect_uri );
|
||||
|
||||
return new \WP_REST_Response(
|
||||
null,
|
||||
302,
|
||||
array( 'Location' => $redirect_url )
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Client Registration and Metadata REST Controller.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest\OAuth;
|
||||
|
||||
use Activitypub\OAuth\Client;
|
||||
use Activitypub\OAuth\Scope;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
|
||||
use function Activitypub\get_client_ip;
|
||||
|
||||
/**
|
||||
* Clients_Controller class for handling OAuth 2.0 client and metadata endpoints.
|
||||
*
|
||||
* Implements:
|
||||
* - Dynamic client registration (POST /oauth/clients)
|
||||
* - Authorization Server Metadata (GET /oauth/authorization-server-metadata)
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
class Clients_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'oauth';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
// Dynamic client registration (RFC 7591).
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/clients',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'register_client' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'client_name' => array(
|
||||
'description' => 'Human-readable name of the client.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'redirect_uris' => array(
|
||||
'description' => 'Array of redirect URIs. Supports custom URI schemes for native apps.',
|
||||
'type' => 'array',
|
||||
'items' => array(
|
||||
'type' => 'string',
|
||||
),
|
||||
'required' => true,
|
||||
),
|
||||
'client_uri' => array(
|
||||
'description' => 'URL of the client homepage.',
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
),
|
||||
'scope' => array(
|
||||
'description' => 'Space-separated list of requested scopes.',
|
||||
'type' => 'string',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Authorization Server Metadata (RFC 8414).
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/authorization-server-metadata',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_metadata' ),
|
||||
'permission_callback' => '__return_true',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dynamic client registration (POST /oauth/clients).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function register_client( \WP_REST_Request $request ) {
|
||||
/**
|
||||
* Filters whether RFC 7591 dynamic client registration is allowed.
|
||||
*
|
||||
* Enabled by default so C2S clients can register on the fly.
|
||||
* Return false to restrict registration to pre-configured clients only.
|
||||
*
|
||||
* @param bool $allowed Whether dynamic registration is allowed. Default true.
|
||||
*/
|
||||
if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_registration_disabled',
|
||||
\__( 'Dynamic client registration is not allowed.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Rate-limit registrations to prevent DB spam (max 10 per minute per IP).
|
||||
$ip = get_client_ip();
|
||||
if ( '' === $ip ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_rate_limited',
|
||||
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
$transient_key = 'ap_oauth_reg_' . \md5( $ip );
|
||||
$count = (int) \get_transient( $transient_key );
|
||||
|
||||
if ( $count >= 10 ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_rate_limited',
|
||||
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
|
||||
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$client_name = $request->get_param( 'client_name' );
|
||||
$redirect_uris = $request->get_param( 'redirect_uris' );
|
||||
$client_uri = $request->get_param( 'client_uri' );
|
||||
$scope = $request->get_param( 'scope' );
|
||||
|
||||
$result = Client::register(
|
||||
array(
|
||||
'name' => $client_name,
|
||||
'redirect_uris' => $redirect_uris,
|
||||
'description' => $client_uri ?? '',
|
||||
'is_public' => true, // Dynamic clients are always public.
|
||||
'scopes' => $scope ? Scope::parse( $scope ) : Scope::ALL,
|
||||
)
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// RFC 7591 response format.
|
||||
$response = array(
|
||||
'client_id' => $result['client_id'],
|
||||
'client_name' => $client_name,
|
||||
'redirect_uris' => $redirect_uris,
|
||||
'token_endpoint_auth_method' => 'none',
|
||||
);
|
||||
|
||||
if ( isset( $result['client_secret'] ) ) {
|
||||
$response['client_secret'] = $result['client_secret'];
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( $response, 201 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth server metadata.
|
||||
*
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function get_metadata() {
|
||||
return new \WP_REST_Response(
|
||||
OAuth_Server::get_metadata(),
|
||||
200,
|
||||
array( 'Content-Type' => 'application/json' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,432 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Token REST Controller.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest\OAuth;
|
||||
|
||||
use Activitypub\OAuth\Authorization_Code;
|
||||
use Activitypub\OAuth\Client;
|
||||
use Activitypub\OAuth\Scope;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
use Activitypub\OAuth\Token;
|
||||
|
||||
use function Activitypub\get_client_ip;
|
||||
|
||||
/**
|
||||
* Token_Controller class for handling OAuth 2.0 token endpoints.
|
||||
*
|
||||
* Implements:
|
||||
* - Token endpoint (POST /oauth/token)
|
||||
* - Revocation endpoint (POST /oauth/revoke)
|
||||
* - Token introspection endpoint (POST /oauth/introspect)
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
class Token_Controller extends \WP_REST_Controller {
|
||||
/**
|
||||
* The namespace of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
||||
|
||||
/**
|
||||
* The base of this controller's route.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rest_base = 'oauth';
|
||||
|
||||
/**
|
||||
* Register routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
// Token endpoint.
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/token',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'token' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'grant_type' => array(
|
||||
'description' => 'The grant type.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => array( 'authorization_code', 'refresh_token' ),
|
||||
),
|
||||
'client_id' => array(
|
||||
'description' => 'The OAuth client identifier.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'client_secret' => array(
|
||||
'description' => 'The OAuth client secret (for confidential clients).',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code' => array(
|
||||
'description' => 'The authorization code (for authorization_code grant).',
|
||||
'type' => 'string',
|
||||
),
|
||||
'redirect_uri' => array(
|
||||
'description' => 'The redirect URI (must match original for authorization_code grant). Supports custom URI schemes for native apps.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'code_verifier' => array(
|
||||
'description' => 'PKCE code verifier.',
|
||||
'type' => 'string',
|
||||
),
|
||||
'refresh_token' => array(
|
||||
'description' => 'The refresh token (for refresh_token grant).',
|
||||
'type' => 'string',
|
||||
),
|
||||
'scope' => array(
|
||||
'description' => 'Space-separated list of requested scopes.',
|
||||
'type' => 'string',
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Revocation endpoint (RFC 7009 — requires authentication).
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/revoke',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'revoke' ),
|
||||
'permission_callback' => array( $this, 'revoke_permissions_check' ),
|
||||
'args' => array(
|
||||
'token' => array(
|
||||
'description' => 'The token to revoke.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'token_type_hint' => array(
|
||||
'description' => 'Hint about the token type.',
|
||||
'type' => 'string',
|
||||
'enum' => array( 'access_token', 'refresh_token' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Token introspection endpoint (RFC 7662).
|
||||
\register_rest_route(
|
||||
$this->namespace,
|
||||
'/' . $this->rest_base . '/introspect',
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'introspect' ),
|
||||
'permission_callback' => array( $this, 'introspect_permissions_check' ),
|
||||
'args' => array(
|
||||
'token' => array(
|
||||
'description' => 'The token to introspect.',
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'token_type_hint' => array(
|
||||
'description' => 'Hint about the token type.',
|
||||
'type' => 'string',
|
||||
'enum' => array( 'access_token', 'refresh_token' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token request (POST /oauth/token).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
public function token( \WP_REST_Request $request ) {
|
||||
// Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP).
|
||||
$ip = get_client_ip();
|
||||
if ( '' === $ip ) {
|
||||
return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
|
||||
}
|
||||
$transient_key = 'ap_oauth_tok_' . \md5( $ip );
|
||||
$count = (int) \get_transient( $transient_key );
|
||||
|
||||
if ( $count >= 20 ) {
|
||||
return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
|
||||
}
|
||||
|
||||
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
$grant_type = $request->get_param( 'grant_type' );
|
||||
|
||||
/*
|
||||
* Extract client credentials from either:
|
||||
* - client_secret_basic: HTTP Basic Auth header (RFC 6749 Section 2.3.1)
|
||||
* - client_secret_post: POST body parameters
|
||||
*/
|
||||
$client_id = null;
|
||||
$client_secret = null;
|
||||
$auth_header = $request->get_header( 'Authorization' );
|
||||
|
||||
if ( $auth_header && 0 === \strpos( $auth_header, 'Basic ' ) ) {
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required by OAuth spec.
|
||||
$decoded = \base64_decode( \substr( $auth_header, 6 ), true );
|
||||
if ( $decoded && false !== \strpos( $decoded, ':' ) ) {
|
||||
list( $client_id, $client_secret ) = \explode( ':', $decoded, 2 );
|
||||
$client_id = \urldecode( $client_id );
|
||||
$client_secret = \urldecode( $client_secret );
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to POST body parameters (client_secret_post).
|
||||
if ( ! $client_id ) {
|
||||
$client_id = $request->get_param( 'client_id' );
|
||||
$client_secret = $request->get_param( 'client_secret' );
|
||||
}
|
||||
|
||||
// Validate client.
|
||||
$client = Client::get( $client_id );
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return $this->token_error( 'invalid_client', 'Unknown client.' );
|
||||
}
|
||||
|
||||
// Validate client credentials if confidential.
|
||||
if ( ! $client->is_public() ) {
|
||||
if ( ! Client::validate( $client_id, $client_secret ) ) {
|
||||
return $this->token_error( 'invalid_client', 'Invalid client credentials.' );
|
||||
}
|
||||
}
|
||||
|
||||
switch ( $grant_type ) {
|
||||
case 'authorization_code':
|
||||
return $this->handle_authorization_code_grant( $request, $client_id );
|
||||
|
||||
case 'refresh_token':
|
||||
return $this->handle_refresh_token_grant( $request, $client_id );
|
||||
|
||||
default:
|
||||
return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authorization code grant.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param string $client_id The client ID.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) {
|
||||
$code = $request->get_param( 'code' );
|
||||
$redirect_uri = $request->get_param( 'redirect_uri' );
|
||||
$code_verifier = $request->get_param( 'code_verifier' );
|
||||
|
||||
if ( empty( $code ) ) {
|
||||
return $this->token_error( 'invalid_request', 'Authorization code is required.' );
|
||||
}
|
||||
|
||||
$result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $this->token_error( 'invalid_grant', $result->get_error_message() );
|
||||
}
|
||||
|
||||
return $this->token_response( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh token grant.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param string $client_id The client ID.
|
||||
* @return \WP_REST_Response|\WP_Error
|
||||
*/
|
||||
private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) {
|
||||
$refresh_token = $request->get_param( 'refresh_token' );
|
||||
|
||||
if ( empty( $refresh_token ) ) {
|
||||
return $this->token_error( 'invalid_request', 'Refresh token is required.' );
|
||||
}
|
||||
|
||||
$result = Token::refresh( $refresh_token, $client_id );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $this->token_error( 'invalid_grant', $result->get_error_message() );
|
||||
}
|
||||
|
||||
return $this->token_response( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token revocation (POST /oauth/revoke).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function revoke( \WP_REST_Request $request ) {
|
||||
$token = $request->get_param( 'token' );
|
||||
|
||||
if ( \current_user_can( 'manage_options' ) ) {
|
||||
// Site admins may revoke any token. Null-null disables the ownership check.
|
||||
Token::revoke( $token );
|
||||
} else {
|
||||
/*
|
||||
* RFC 7009 §2.1: the server must verify the token was issued to
|
||||
* the requesting client. When the caller authenticated with a
|
||||
* bearer token we know the calling client, so require a client
|
||||
* match and ignore the user — otherwise a low-trust client
|
||||
* could revoke tokens the user had granted to a different
|
||||
* client. For pure cookie-authenticated callers there is no
|
||||
* client context, so user match is the only available check.
|
||||
*/
|
||||
$caller_token = OAuth_Server::get_current_token();
|
||||
if ( $caller_token ) {
|
||||
Token::revoke( $token, null, $caller_token->get_client_id() );
|
||||
} else {
|
||||
Token::revoke( $token, \get_current_user_id(), null );
|
||||
}
|
||||
}
|
||||
|
||||
// Per RFC 7009, always return 200 even if the token doesn't exist or was not owned.
|
||||
return new \WP_REST_Response( null, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token introspection (POST /oauth/introspect).
|
||||
*
|
||||
* Implements RFC 7662 Token Introspection.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function introspect( \WP_REST_Request $request ) {
|
||||
$token = $request->get_param( 'token' );
|
||||
|
||||
// Introspect the token.
|
||||
$response = Token::introspect( $token );
|
||||
|
||||
// Scope introspection to same client: non-admin users can only
|
||||
// introspect tokens belonging to the same client as their own.
|
||||
if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) {
|
||||
$current_token = OAuth_Server::get_current_token();
|
||||
if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) {
|
||||
$response = array( 'active' => false );
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( $response, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check for token revocation.
|
||||
*
|
||||
* Per RFC 7009, the revocation endpoint must be protected.
|
||||
* Requires either a logged-in user or a valid Bearer token.
|
||||
*
|
||||
* @return bool|\WP_Error True if allowed, error otherwise.
|
||||
*/
|
||||
public function revoke_permissions_check() {
|
||||
if ( \is_user_logged_in() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$token = OAuth_Server::get_bearer_token();
|
||||
|
||||
if ( $token ) {
|
||||
$validated = Token::validate( $token );
|
||||
|
||||
if ( ! \is_wp_error( $validated ) ) {
|
||||
\wp_set_current_user( $validated->get_user_id() );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_unauthorized',
|
||||
\__( 'Authentication required.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check for token introspection.
|
||||
*
|
||||
* Per RFC 7662, the introspection endpoint must be protected.
|
||||
*
|
||||
* @return bool|\WP_Error True if allowed, error otherwise.
|
||||
*/
|
||||
public function introspect_permissions_check() {
|
||||
if ( \is_user_logged_in() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Support Bearer token auth for public OAuth clients.
|
||||
$token = OAuth_Server::get_bearer_token();
|
||||
|
||||
if ( $token ) {
|
||||
$validated = Token::validate( $token );
|
||||
|
||||
if ( ! \is_wp_error( $validated ) ) {
|
||||
\wp_set_current_user( $validated->get_user_id() );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_unauthorized',
|
||||
\__( 'Authentication required.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a token error response.
|
||||
*
|
||||
* @param string $error Error code.
|
||||
* @param string $error_description Error description.
|
||||
* @param int $status Optional. HTTP status code. Defaults to 400 per RFC 6749 §5.2;
|
||||
* callers should pass 429 for rate-limit responses (RFC 6585).
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
private function token_error( $error, $error_description, $status = 400 ) {
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'error' => $error,
|
||||
'error_description' => $error_description,
|
||||
),
|
||||
$status,
|
||||
array(
|
||||
'Content-Type' => 'application/json',
|
||||
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
|
||||
'Cache-Control' => 'no-store',
|
||||
'Pragma' => 'no-cache',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a token success response.
|
||||
*
|
||||
* @param array $token_data Token data.
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
private function token_response( $token_data ) {
|
||||
return new \WP_REST_Response(
|
||||
$token_data,
|
||||
200,
|
||||
array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Cache-Control' => 'no-store',
|
||||
'Pragma' => 'no-cache',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,15 @@ namespace Activitypub\Rest;
|
||||
* and type transitions between Collection and CollectionPage.
|
||||
*/
|
||||
trait Collection {
|
||||
/**
|
||||
* The JSON-LD context for ActivityPub collections.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $json_ld_context = array(
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
);
|
||||
|
||||
/**
|
||||
* Prepares a collection response by adding navigation links and handling pagination.
|
||||
*
|
||||
@ -23,12 +32,13 @@ trait Collection {
|
||||
*
|
||||
* @param array $response The collection response array.
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return array|\WP_Error The response array with navigation links or WP_Error on invalid page.
|
||||
*/
|
||||
public function prepare_collection_response( $response, $request ) {
|
||||
$page = $request->get_param( 'page' );
|
||||
$per_page = $request->get_param( 'per_page' );
|
||||
$max_pages = \ceil( $response['totalItems'] / $per_page );
|
||||
$per_page = \max( 1, \absint( $request->get_param( 'per_page' ) ) );
|
||||
$max_pages = \max( 1, \ceil( $response['totalItems'] / $per_page ) );
|
||||
|
||||
if ( $page > $max_pages ) {
|
||||
return new \WP_Error(
|
||||
@ -38,11 +48,18 @@ trait Collection {
|
||||
);
|
||||
}
|
||||
|
||||
// No need to add links if there's only one page.
|
||||
if ( 1 >= $max_pages && null === $page ) {
|
||||
// Set the JSON-LD context if not already set.
|
||||
if ( empty( $response['@context'] ) ) {
|
||||
// Ensure the context is the first element in the response.
|
||||
$response = array( '@context' => $this->json_ld_context ) + $response;
|
||||
}
|
||||
|
||||
if ( empty( $response['items'] ) && empty( $response['orderedItems'] ) ) {
|
||||
// Skip pagination metadata when items are intentionally hidden or collection is empty.
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response['id'] = \add_query_arg( $request->get_query_params(), $response['id'] );
|
||||
$response['first'] = \add_query_arg( 'page', 1, $response['id'] );
|
||||
$response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] );
|
||||
|
||||
@ -56,15 +73,14 @@ trait Collection {
|
||||
|
||||
// Still here, so this is a Page request. Append the type.
|
||||
$response['type'] .= 'Page';
|
||||
$response['partOf'] = $response['id'];
|
||||
$response['id'] .= '?page=' . $page;
|
||||
$response['partOf'] = \remove_query_arg( 'page', $response['id'] );
|
||||
|
||||
if ( $max_pages > $page ) {
|
||||
$response['next'] = \add_query_arg( 'page', $page + 1, $response['id'] );
|
||||
$response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] );
|
||||
}
|
||||
|
||||
if ( $page > 1 ) {
|
||||
$response['prev'] = \add_query_arg( 'page', $page - 1, $response['id'] );
|
||||
$response['prev'] = \add_query_arg( 'page', $page - 1, $response['partOf'] );
|
||||
}
|
||||
|
||||
return $response;
|
||||
@ -77,6 +93,7 @@ trait Collection {
|
||||
* that controllers can use to compose their full schema by passing in their item schema.
|
||||
*
|
||||
* @param array $item_schema Optional. The schema for the items in the collection. Default empty array.
|
||||
*
|
||||
* @return array The collection schema.
|
||||
*/
|
||||
public function get_collection_schema( $item_schema = array() ) {
|
||||
|
||||
@ -0,0 +1,573 @@
|
||||
<?php
|
||||
/**
|
||||
* Event Stream Trait file.
|
||||
*
|
||||
* Provides Server-Sent Events (SSE) functionality for real-time
|
||||
* ActivityPub collection updates and proxy streaming.
|
||||
*
|
||||
* @package Activitypub
|
||||
* @see https://swicg.github.io/activitypub-api/sse
|
||||
* @since 8.1.0
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Inbox;
|
||||
use Activitypub\Collection\Outbox;
|
||||
use Activitypub\OAuth\Scope;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
|
||||
use function Activitypub\resolve_public_host;
|
||||
|
||||
|
||||
/**
|
||||
* Event Stream Trait.
|
||||
*
|
||||
* Provides SSE streaming capabilities for collection controllers
|
||||
* (Outbox, Inbox) and the Proxy controller.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
trait Event_Stream {
|
||||
|
||||
/**
|
||||
* Map of ActivityPub activity types to SSE event types.
|
||||
*
|
||||
* @see https://swicg.github.io/activitypub-api/sse
|
||||
*
|
||||
* @return array The event type map.
|
||||
*/
|
||||
protected static function get_event_type_map() {
|
||||
return array(
|
||||
'Create' => 'Add',
|
||||
'Announce' => 'Add',
|
||||
'Like' => 'Add',
|
||||
'Update' => 'Update',
|
||||
'Delete' => 'Delete',
|
||||
'Undo' => 'Remove',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permissions for the stream endpoint.
|
||||
*
|
||||
* Requires OAuth authentication with the push scope.
|
||||
* Falls back to `access_token` query parameter for EventSource clients,
|
||||
* since the browser EventSource API cannot send custom headers.
|
||||
*
|
||||
* @see https://swicg.github.io/activitypub-api/sse
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
||||
*/
|
||||
public function get_stream_permissions_check( $request ) {
|
||||
// If not already OAuth-authenticated, try the access_token query parameter.
|
||||
if ( ! OAuth_Server::is_oauth_request() ) {
|
||||
$this->authenticate_from_query_param();
|
||||
}
|
||||
|
||||
$oauth_result = OAuth_Server::check_oauth_permission( $request, Scope::PUSH );
|
||||
|
||||
if ( true !== $oauth_result ) {
|
||||
return $oauth_result;
|
||||
}
|
||||
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
|
||||
if ( null === $user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->verify_owner( $request );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate from the access_token query parameter.
|
||||
*
|
||||
* The browser EventSource API cannot send custom headers, so SSE
|
||||
* clients pass the OAuth token as a query parameter. This method
|
||||
* injects it as an Authorization header and re-runs OAuth
|
||||
* authentication so the server recognizes the request.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @see https://swicg.github.io/activitypub-api/sse
|
||||
*/
|
||||
private function authenticate_from_query_param() {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered.
|
||||
if ( empty( $_GET['access_token'] ) || ! \is_string( $_GET['access_token'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token_string = \wp_unslash( $_GET['access_token'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
|
||||
// Reject tokens that are too long or contain unexpected characters.
|
||||
if ( \strlen( $token_string ) > 512 || \preg_match( '/[^A-Za-z0-9._~+\/-]/', $token_string ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject as Authorization header so the OAuth server can find it.
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token_string;
|
||||
|
||||
// Re-run OAuth authentication.
|
||||
OAuth_Server::authenticate_oauth( null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream SSE events for a collection.
|
||||
*
|
||||
* Sends raw SSE output and calls exit.
|
||||
*
|
||||
* @param int $user_id The actor ID.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*/
|
||||
protected function stream_collection( $user_id, $collection ) {
|
||||
// Allow PHP to detect client disconnects instead of auto-terminating.
|
||||
ignore_user_abort( true );
|
||||
|
||||
// Extend PHP execution time for long-lived SSE connections.
|
||||
set_time_limit( 0 );
|
||||
|
||||
$this->send_sse_headers();
|
||||
|
||||
// Honor Last-Event-ID for reconnecting clients (per SSE spec).
|
||||
$last_event_id = isset( $_SERVER['HTTP_LAST_EVENT_ID'] )
|
||||
? \absint( \wp_unslash( $_SERVER['HTTP_LAST_EVENT_ID'] ) )
|
||||
: 0;
|
||||
|
||||
// Use Last-Event-ID if provided, otherwise start from the latest item.
|
||||
$since_id = $last_event_id ? $last_event_id : $this->get_latest_item_id( $user_id, $collection );
|
||||
$start = time();
|
||||
|
||||
$this->send_sse_comment( 'connected' );
|
||||
|
||||
while ( ( time() - $start ) < 300 ) {
|
||||
if ( \connection_aborted() ) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for signal transient before querying the DB.
|
||||
$signal_key = sprintf( 'activitypub_sse_signal_%s_%s', $user_id, $collection );
|
||||
$signal = \get_transient( $signal_key );
|
||||
|
||||
if ( $signal ) {
|
||||
\delete_transient( $signal_key );
|
||||
|
||||
$new_items = $this->get_new_items( $user_id, $collection, $since_id );
|
||||
|
||||
foreach ( $new_items as $item ) {
|
||||
$this->send_sse_event( $item, $collection );
|
||||
|
||||
if ( $item->ID > $since_id ) {
|
||||
$since_id = $item->ID;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-set signal if we hit the limit, so remaining items are fetched next iteration.
|
||||
if ( count( $new_items ) >= 20 ) {
|
||||
\set_transient( $signal_key, time(), 5 * MINUTE_IN_SECONDS );
|
||||
}
|
||||
}
|
||||
|
||||
$this->send_sse_comment( 'keepalive ' . \gmdate( 'c' ) );
|
||||
$this->flush_output();
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_sleep -- SSE long-polling requires blocking sleep.
|
||||
sleep( 5 );
|
||||
}
|
||||
|
||||
$this->send_sse_comment( 'timeout' );
|
||||
$this->flush_output();
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a streaming connection to a remote SSE endpoint and relay events.
|
||||
*
|
||||
* Uses PHP streams directly because the WordPress HTTP API
|
||||
* does not support streaming responses.
|
||||
*
|
||||
* @param string $stream_url The remote eventStream URL.
|
||||
*/
|
||||
protected function relay_remote_stream( $stream_url ) {
|
||||
ignore_user_abort( true );
|
||||
|
||||
// Extend PHP execution time for long-lived SSE connections.
|
||||
set_time_limit( 0 );
|
||||
|
||||
$parsed = \wp_parse_url( $stream_url );
|
||||
$host = $parsed['host'];
|
||||
$port = isset( $parsed['port'] ) ? $parsed['port'] : 443;
|
||||
$path = isset( $parsed['path'] ) ? $parsed['path'] : '/';
|
||||
|
||||
if ( isset( $parsed['query'] ) ) {
|
||||
$path .= '?' . $parsed['query'];
|
||||
}
|
||||
|
||||
/*
|
||||
* Resolve the host once and verify every returned address is public.
|
||||
* stream_socket_client would otherwise do its own lookup, which opens
|
||||
* a DNS-rebinding window after validate_url() already passed.
|
||||
*/
|
||||
$ip = resolve_public_host( $host );
|
||||
if ( false === $ip ) {
|
||||
\status_header( 502 );
|
||||
\header( 'Content-Type: application/json' );
|
||||
Server::send_cors_headers();
|
||||
echo \wp_json_encode(
|
||||
array(
|
||||
'code' => 'activitypub_proxy_unsafe_host',
|
||||
'message' => \__( 'The remote eventStream host is not allowed.', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
$context = stream_context_create(
|
||||
array(
|
||||
'ssl' => array(
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
// Pin SNI / cert verification to the original hostname even though we connect by IP.
|
||||
'peer_name' => $host,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$target = ( false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip ) . ':' . $port;
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_stream_socket_client -- SSE proxy requires raw streaming.
|
||||
$stream = stream_socket_client(
|
||||
'ssl://' . $target,
|
||||
$errno,
|
||||
$errstr,
|
||||
30,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
|
||||
if ( ! $stream ) {
|
||||
\status_header( 502 );
|
||||
\header( 'Content-Type: application/json' );
|
||||
Server::send_cors_headers();
|
||||
echo \wp_json_encode(
|
||||
array(
|
||||
'code' => 'activitypub_proxy_connection_failed',
|
||||
'message' => \__( 'Failed to connect to the remote eventStream.', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Send the HTTP request.
|
||||
$request_headers = "GET {$path} HTTP/1.1\r\n";
|
||||
$request_headers .= "Host: {$host}\r\n";
|
||||
$request_headers .= "Accept: text/event-stream\r\n";
|
||||
$request_headers .= "Cache-Control: no-cache\r\n";
|
||||
$request_headers .= "Connection: keep-alive\r\n";
|
||||
$request_headers .= "\r\n";
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Raw stream operation.
|
||||
fwrite( $stream, $request_headers );
|
||||
|
||||
// Read and skip the HTTP response headers.
|
||||
$header_complete = false;
|
||||
$status_code = 0;
|
||||
|
||||
while ( ! feof( $stream ) ) {
|
||||
$line = fgets( $stream, 8192 );
|
||||
|
||||
if ( false === $line ) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ( ! $status_code && preg_match( '/^HTTP\/\d\.\d (\d{3})/', $line, $matches ) ) {
|
||||
$status_code = (int) $matches[1];
|
||||
}
|
||||
|
||||
// Empty line signals end of headers.
|
||||
if ( "\r\n" === $line || "\n" === $line ) {
|
||||
$header_complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $header_complete || 200 !== $status_code ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Raw stream operation.
|
||||
fclose( $stream );
|
||||
\status_header( 502 );
|
||||
\header( 'Content-Type: application/json' );
|
||||
Server::send_cors_headers();
|
||||
echo \wp_json_encode(
|
||||
array(
|
||||
'code' => 'activitypub_proxy_stream_error',
|
||||
'message' => \__( 'The remote eventStream returned an error.', 'activitypub' ),
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Send our own SSE headers and relay the remote stream.
|
||||
$this->send_sse_headers();
|
||||
$this->send_sse_comment( 'proxying ' . $host );
|
||||
|
||||
$start = time();
|
||||
|
||||
stream_set_timeout( $stream, 10 );
|
||||
|
||||
while ( ! feof( $stream ) && ( time() - $start ) < 300 ) {
|
||||
if ( \connection_aborted() ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$line = fgets( $stream, 8192 );
|
||||
|
||||
if ( false === $line ) {
|
||||
$meta = stream_get_meta_data( $stream );
|
||||
|
||||
if ( ! empty( $meta['timed_out'] ) ) {
|
||||
$this->send_sse_comment( 'keepalive ' . \gmdate( 'c' ) );
|
||||
$this->flush_output();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Relaying raw SSE protocol data.
|
||||
echo $line;
|
||||
$this->flush_output();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Raw stream operation.
|
||||
fclose( $stream );
|
||||
|
||||
$this->send_sse_comment( 'proxy timeout' );
|
||||
$this->flush_output();
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SSE-specific HTTP headers.
|
||||
*/
|
||||
protected function send_sse_headers() {
|
||||
while ( ob_get_level() > 0 ) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
\status_header( 200 );
|
||||
\header( 'Content-Type: text/event-stream' );
|
||||
\header( 'Cache-Control: no-cache, no-store' );
|
||||
\header( 'Referrer-Policy: no-referrer' );
|
||||
\header( 'X-Accel-Buffering: no' );
|
||||
|
||||
// SSE exits before rest_post_dispatch, so CORS must be sent directly.
|
||||
Server::send_cors_headers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SSE event for a collection item.
|
||||
*
|
||||
* @param \WP_Post $item The collection post item.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*/
|
||||
protected function send_sse_event( $item, $collection ) {
|
||||
$event_type = $this->get_event_type( $item, $collection );
|
||||
$data = $this->get_event_data( $item, $collection );
|
||||
|
||||
if ( ! $data ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol requires raw output.
|
||||
echo 'event: ' . $event_type . "\n";
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol requires raw JSON output.
|
||||
echo 'data: ' . \wp_json_encode( $data ) . "\n";
|
||||
echo 'id: ' . (int) $item->ID . "\n\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SSE comment line.
|
||||
*
|
||||
* @param string $comment The comment text.
|
||||
*/
|
||||
protected function send_sse_comment( $comment ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol requires raw output.
|
||||
echo ': ' . $comment . "\n\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all output buffers.
|
||||
*/
|
||||
protected function flush_output() {
|
||||
if ( ob_get_level() > 0 ) {
|
||||
ob_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSE event type for a collection item.
|
||||
*
|
||||
* @param \WP_Post $item The collection post item.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*
|
||||
* @return string The SSE event type.
|
||||
*/
|
||||
protected function get_event_type( $item, $collection ) {
|
||||
if ( 'inbox' === $collection ) {
|
||||
return 'Add';
|
||||
}
|
||||
|
||||
$activity_type = \get_post_meta( $item->ID, '_activitypub_activity_type', true );
|
||||
$event_type_map = self::get_event_type_map();
|
||||
|
||||
if ( isset( $event_type_map[ $activity_type ] ) ) {
|
||||
return $event_type_map[ $activity_type ];
|
||||
}
|
||||
|
||||
return 'Add';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the activity data for a collection item.
|
||||
*
|
||||
* @param \WP_Post $item The collection post item.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*
|
||||
* @return array|null The activity data, or null on failure.
|
||||
*/
|
||||
protected function get_event_data( $item, $collection ) {
|
||||
if ( 'outbox' === $collection ) {
|
||||
$activity = Outbox::get_activity( $item->ID );
|
||||
|
||||
if ( \is_wp_error( $activity ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $activity->to_array( false );
|
||||
}
|
||||
|
||||
$data = \json_decode( $item->post_content, true );
|
||||
|
||||
return $data ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest item ID for a collection.
|
||||
*
|
||||
* @param int $user_id The actor ID.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*
|
||||
* @return int The latest post ID, or 0 if empty.
|
||||
*/
|
||||
protected function get_latest_item_id( $user_id, $collection ) {
|
||||
$post_type = 'outbox' === $collection ? Outbox::POST_TYPE : Inbox::POST_TYPE;
|
||||
|
||||
$args = array(
|
||||
'post_type' => $post_type,
|
||||
'post_status' => 'any',
|
||||
'posts_per_page' => 1,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'DESC',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
);
|
||||
|
||||
if ( 'outbox' === $collection ) {
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
$args['meta_query'] = array(
|
||||
array(
|
||||
'key' => '_activitypub_activity_actor',
|
||||
'value' => Actors::get_type_by_id( $user_id ),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
$args['meta_query'] = array(
|
||||
array(
|
||||
'key' => '_activitypub_user_id',
|
||||
'value' => $user_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$query = new \WP_Query( $args );
|
||||
|
||||
return ! empty( $query->posts ) ? (int) $query->posts[0] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the eventStream URL for a collection.
|
||||
*
|
||||
* @param int $user_id The actor ID.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
*
|
||||
* @return string The eventStream URL.
|
||||
*/
|
||||
public function get_stream_url( $user_id, $collection ) {
|
||||
return \rest_url( sprintf( '%s/actors/%d/%s/stream', $this->namespace, $user_id, $collection ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new collection items since a given ID.
|
||||
*
|
||||
* @param int $user_id The actor ID.
|
||||
* @param string $collection The collection type ('outbox' or 'inbox').
|
||||
* @param int $since_id Only return items with ID greater than this.
|
||||
*
|
||||
* @return \WP_Post[] Array of new post items.
|
||||
*/
|
||||
protected function get_new_items( $user_id, $collection, $since_id ) {
|
||||
$post_type = 'outbox' === $collection ? Outbox::POST_TYPE : Inbox::POST_TYPE;
|
||||
|
||||
$args = array(
|
||||
'post_type' => $post_type,
|
||||
'post_status' => 'any',
|
||||
'posts_per_page' => 20,
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
'no_found_rows' => true,
|
||||
);
|
||||
|
||||
if ( 'outbox' === $collection ) {
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
$args['meta_query'] = array(
|
||||
array(
|
||||
'key' => '_activitypub_activity_actor',
|
||||
'value' => Actors::get_type_by_id( $user_id ),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
$args['meta_query'] = array(
|
||||
array(
|
||||
'key' => '_activitypub_user_id',
|
||||
'value' => $user_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ( $since_id > 0 ) {
|
||||
$where_filter = function ( $where ) use ( $since_id ) {
|
||||
global $wpdb;
|
||||
$where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $since_id );
|
||||
return $where;
|
||||
};
|
||||
\add_filter( 'posts_where', $where_filter );
|
||||
}
|
||||
|
||||
$query = new \WP_Query( $args );
|
||||
|
||||
if ( $since_id > 0 ) {
|
||||
\remove_filter( 'posts_where', $where_filter );
|
||||
}
|
||||
|
||||
return $query->posts;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Language_Map Trait file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
/**
|
||||
* Language_Map Trait.
|
||||
*
|
||||
* Provides methods for resolving ActivityStreams natural language values.
|
||||
*
|
||||
* Properties like `summary`, `content`, and `name` should be plain strings.
|
||||
* Language maps should use the `*Map` variant (`summaryMap`, `contentMap`,
|
||||
* `nameMap`).
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @see https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues
|
||||
* @see https://www.w3.org/wiki/Activity_Streams/Primer/Language_mapping
|
||||
*/
|
||||
trait Language_Map {
|
||||
|
||||
/**
|
||||
* Default fallback language code.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fallback_language = 'en';
|
||||
|
||||
/**
|
||||
* Localize language map properties in an activity object array.
|
||||
*
|
||||
* Normalizes `summary`, `content`, and `name` (and their `*Map` variants)
|
||||
* to plain strings. Also recurses into nested `object` properties.
|
||||
*
|
||||
* Can be used as a sanitize_callback for REST API args.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param mixed $data The activity object data (array or string URI).
|
||||
*
|
||||
* @return mixed The data with language maps resolved, or unchanged if not an array.
|
||||
*/
|
||||
public function localize_language_maps( $data ) {
|
||||
if ( ! \is_array( $data ) ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$properties = array( 'summary', 'content', 'name' );
|
||||
|
||||
foreach ( $properties as $key ) {
|
||||
if ( isset( $data[ $key ] ) || isset( $data[ $key . 'Map' ] ) ) {
|
||||
$data[ $key ] = $this->get_localized_value(
|
||||
isset( $data[ $key ] ) ? $data[ $key ] : null,
|
||||
isset( $data[ $key . 'Map' ] ) ? $data[ $key . 'Map' ] : null,
|
||||
isset( $data['language'] ) ? $data['language'] : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Also normalize within the nested object if it is an array. */
|
||||
if ( isset( $data['object'] ) && \is_array( $data['object'] ) ) {
|
||||
$data['object'] = $this->localize_language_maps( $data['object'] );
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a natural language value to a plain string.
|
||||
*
|
||||
* Resolution priority:
|
||||
* 1. The base property when the object's language matches the site locale.
|
||||
* 2. Site locale or English match in the `*Map` variant.
|
||||
* 3. The base property as a plain string (the default).
|
||||
* 4. First `*Map` entry if no base string and no preferred language match.
|
||||
*
|
||||
* Non-string base values (e.g. arrays) are ignored.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param mixed $value The base property value (only strings are used).
|
||||
* @param array|null $map The `*Map` variant (e.g. `summaryMap`).
|
||||
* @param string|null $object_lang The object's language property.
|
||||
*
|
||||
* @return string|null The resolved string, or null if empty.
|
||||
*/
|
||||
public function get_localized_value( $value, $map, $object_lang ) {
|
||||
$site_lang = \strtolower( \strtok( \get_locale(), '_-' ) );
|
||||
|
||||
/*
|
||||
* If the object's language matches the site locale,
|
||||
* the base property is already in the right language.
|
||||
*/
|
||||
if ( $object_lang && \is_string( $object_lang ) && \is_string( $value ) ) {
|
||||
if ( \strtolower( \strtok( $object_lang, '_-' ) ) === $site_lang ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
$languages = $this->get_preferred_languages( $site_lang );
|
||||
|
||||
/* Check the *Map variant for a locale match. */
|
||||
if ( \is_array( $map ) ) {
|
||||
$resolved = $this->resolve_language_map( $map, $languages );
|
||||
if ( $resolved ) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if ( \is_string( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
/* No base value and no language match: use first map entry. */
|
||||
if ( \is_array( $map ) && ! empty( $map ) ) {
|
||||
return \current( $map );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred language codes in priority order.
|
||||
*
|
||||
* Returns the site locale as primary, with English as fallback
|
||||
* (unless the site is already English). Additional languages can
|
||||
* be added via the `activitypub_preferred_languages` filter.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param string $site_lang The site's primary language code (e.g. 'de').
|
||||
*
|
||||
* @return string[] Language codes in priority order.
|
||||
*/
|
||||
public function get_preferred_languages( $site_lang ) {
|
||||
$languages = array( $site_lang );
|
||||
|
||||
if ( $this->fallback_language !== $site_lang ) {
|
||||
$languages[] = $this->fallback_language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the preferred language codes for language map resolution.
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param string[] $languages Preferred language codes in priority order.
|
||||
* @param string $site_lang The site's primary language code.
|
||||
*/
|
||||
return \apply_filters( 'activitypub_preferred_languages', $languages, $site_lang );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a language map to a single string.
|
||||
*
|
||||
* Tries each preferred language in order (site locale, then English).
|
||||
*
|
||||
* @since 8.0.0
|
||||
*
|
||||
* @param array $map The language map (e.g. `{"en": "Hello", "de": "Hallo"}`).
|
||||
* @param string[] $languages Preferred language codes in priority order (e.g. `['de', 'en']`).
|
||||
*
|
||||
* @return string|null The matched string, or null if no match found.
|
||||
*/
|
||||
private function resolve_language_map( $map, $languages ) {
|
||||
if ( empty( $map ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ( $languages as $lang ) {
|
||||
if ( isset( $map[ $lang ] ) ) {
|
||||
return $map[ $lang ];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* Verification Trait file.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Rest;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\OAuth\Scope;
|
||||
use Activitypub\OAuth\Server as OAuth_Server;
|
||||
use Activitypub\Signature;
|
||||
|
||||
use function Activitypub\object_to_uri;
|
||||
use function Activitypub\use_authorized_fetch;
|
||||
use function Activitypub\user_can_act_as_blog;
|
||||
|
||||
/**
|
||||
* Verification Trait.
|
||||
*
|
||||
* Provides methods for verifying HTTP Signatures (S2S) and OAuth (C2S).
|
||||
* Controllers can use this trait for permission callbacks.
|
||||
*/
|
||||
trait Verification {
|
||||
/**
|
||||
* Verify HTTP Signature for server-to-server requests.
|
||||
*
|
||||
* Verifies the signature of POST, PUT, PATCH, and DELETE requests,
|
||||
* as well as GET requests when authorized fetch is enabled.
|
||||
* HEAD requests are bypassed by default so caches and link-checkers
|
||||
* can probe public endpoints; callers that pass `$force_signature`
|
||||
* (e.g. FEP-8fcf's `/followers/sync`) require signatures on HEAD too.
|
||||
*
|
||||
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
|
||||
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @param bool $force_signature Optional. When true, GET and HEAD requests also
|
||||
* require a valid signature even with Authorized
|
||||
* Fetch disabled. Use for endpoints that are
|
||||
* peer-only (e.g. FEP-8fcf's `/followers/sync`).
|
||||
* Default false.
|
||||
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
||||
*/
|
||||
public function verify_signature( $request, $force_signature = false ) {
|
||||
if ( 'HEAD' === $request->get_method() && ! $force_signature ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to defer signature verification.
|
||||
*
|
||||
* Skip signature verification for debugging purposes or to reduce load for
|
||||
* certain Activity-Types, like "Delete". Callers that want to preserve
|
||||
* mandatory signing for endpoints passing `$force_signature = true`
|
||||
* (e.g. FEP-8fcf's `/followers/sync`) should inspect the third argument
|
||||
* and return `false` in that case.
|
||||
*
|
||||
* @param bool $defer Whether to defer signature verification.
|
||||
* @param \WP_REST_Request $request The request used to generate the response.
|
||||
* @param bool $force_signature Whether the caller has forced signature
|
||||
* verification for this endpoint.
|
||||
* @return bool Whether to defer signature verification.
|
||||
*/
|
||||
$defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request, $force_signature );
|
||||
|
||||
if ( $defer ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST-Requests always have to be signed, GET-Requests only require a signature in secure mode or when forced.
|
||||
if ( 'GET' !== $request->get_method() || use_authorized_fetch() || $force_signature ) {
|
||||
$verified_request = Signature::verify_http_signature( $request );
|
||||
if ( \is_wp_error( $verified_request ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_signature_verification',
|
||||
$verified_request->get_error_message(),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the signing key's host matches the activity actor's host.
|
||||
$key_id_check = $this->verify_key_id( $request );
|
||||
if ( \is_wp_error( $key_id_check ) ) {
|
||||
return $key_id_check;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the signature keyId and activity actor share the same host.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return true|\WP_Error True if valid, WP_Error on mismatch.
|
||||
*/
|
||||
private function verify_key_id( $request ) {
|
||||
$sig = $request->get_header( 'signature' );
|
||||
if ( ! $sig || ! \preg_match( '/keyId="([^"]+)"/i', $sig, $m ) ) {
|
||||
// RFC 9421 Signature-Input.
|
||||
$sig = $request->get_header( 'signature-input' );
|
||||
if ( ! $sig || ! \preg_match( '/keyid="([^"]+)"/i', $sig, $m ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$key_host = \strtolower( (string) \wp_parse_url( $m[1], \PHP_URL_HOST ) );
|
||||
$json = $request->get_json_params();
|
||||
$actor = isset( $json['actor'] ) ? object_to_uri( $json['actor'] ) : null;
|
||||
|
||||
if ( ! $actor || ! $key_host ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$actor_host = \strtolower( (string) \wp_parse_url( $actor, \PHP_URL_HOST ) );
|
||||
|
||||
if ( ! $actor_host || $key_host !== $actor_host ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_key_actor_mismatch',
|
||||
\__( 'Signing key and activity actor must be on the same host.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user authentication via OAuth.
|
||||
*
|
||||
* Automatically determines the required scope based on the HTTP method:
|
||||
* - GET, HEAD: read scope
|
||||
* - POST, PUT, PATCH, DELETE: write scope
|
||||
*
|
||||
* If the request has a user_id parameter, also verifies that the
|
||||
* authenticated user matches that actor.
|
||||
*
|
||||
* Application Passwords are not accepted directly on C2S endpoints.
|
||||
*
|
||||
* Security: `check_oauth_permission()` requires a valid Bearer token via
|
||||
* `is_oauth_request()`. Cookie-authenticated sessions never satisfy that
|
||||
* check, so a wp-admin session in another browser tab cannot be hijacked
|
||||
* to drive C2S writes on behalf of the user (no CSRF path on this surface).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
||||
*/
|
||||
public function verify_authentication( $request ) {
|
||||
// Determine scope based on HTTP method.
|
||||
$method = $request->get_method();
|
||||
$read_methods = array( 'GET', 'HEAD' );
|
||||
$scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE;
|
||||
|
||||
$result = OAuth_Server::check_oauth_permission( $request, $scope );
|
||||
if ( true === $result ) {
|
||||
return $this->maybe_verify_owner( $request );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify owner if user_id parameter is present.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
|
||||
*/
|
||||
private function maybe_verify_owner( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
|
||||
if ( null === $user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->verify_owner( $request );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the authenticated user matches the actor specified in the request.
|
||||
*
|
||||
* Checks that the user_id parameter matches the authenticated user.
|
||||
* Works with both OAuth tokens and WordPress session auth (wp-login.php flow).
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return bool|\WP_Error True if the user matches, WP_Error otherwise.
|
||||
*/
|
||||
public function verify_owner( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
|
||||
// Validate the user exists.
|
||||
$user = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
/*
|
||||
* Require an authenticated session before the identity-equality check below.
|
||||
* Without this guard, anonymous requests with `user_id = 0` (blog actor)
|
||||
* would match because `\get_current_user_id()` also returns `0`, exposing
|
||||
* owner-only behaviors such as the hidden social graph for the blog actor.
|
||||
*/
|
||||
if ( ! \is_user_logged_in() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_forbidden',
|
||||
\__( 'You can only access your own resources.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( \get_current_user_id() === (int) $user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The blog actor has no `wp_users` row, so the identity-equality check above
|
||||
// cannot match for a logged-in user. Delegate to the capability helper.
|
||||
if ( Actors::BLOG_USER_ID === (int) $user_id && user_can_act_as_blog() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_forbidden',
|
||||
\__( 'You can only access your own resources.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the social graph should be shown for this request.
|
||||
*
|
||||
* Returns true if the social graph setting allows public display,
|
||||
* or if the request is authenticated by the resource owner.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
* @return bool True if the social graph should be shown.
|
||||
*/
|
||||
protected function show_social_graph( $request ) {
|
||||
$user_id = $request->get_param( 'user_id' );
|
||||
|
||||
return Actors::show_social_graph( $user_id ) || true === $this->verify_owner( $request );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user